Compare commits

...

62 Commits

Author SHA1 Message Date
Nicolas Meienberger
8f1e24a034 chore: update readme 2025-11-05 22:17:11 +01:00
Nicolas Meienberger
1e68339dd9 style: fix snapshots table 2025-11-05 22:00:39 +01:00
Nicolas Meienberger
941be2f306 style(snapshots): fix margin in timeline 2025-11-05 21:31:49 +01:00
Nicolas Meienberger
99d4d46338 feat(restore): delete files not in snapshot option 2025-11-05 19:00:44 +01:00
Nicolas Meienberger
ab997ef450 feat: restore alert dialog 2025-11-05 18:36:48 +01:00
Nicolas Meienberger
9ee5871fbb feat: restore 2025-11-05 18:30:10 +01:00
Nicolas Meienberger
01c2a3669c refactor(backups): tag snapshots by backup id and run forget by grouping first by tags 2025-11-04 20:13:00 +01:00
Nicolas Meienberger
ecd517341c fix(backup-details): re-fetch when selected snapshot is changed 2025-11-04 18:41:30 +01:00
Nicolas Meienberger
ef27ab8ed8 fix(file-tree) select parent even if children are not loaded yet 2025-11-04 18:13:35 +01:00
Nicolas Meienberger
d1e46918ec refactor: simplify snapshot file explorer 2025-11-04 18:01:37 +01:00
Nicolas Meienberger
11ca80a929 feat: backup details snapshots timeline & file explorer 2025-11-03 22:03:55 +01:00
Nicolas Meienberger
f2643436b0 refactor: snapshots flat response 2025-11-03 21:54:31 +01:00
Nicolas Meienberger
acc5f44565 feat: select folders to backup when creating a job 2025-11-02 19:16:24 +01:00
Nicolas Meienberger
ccfa5e35e9 refactor: delete alert dialogs 2025-11-02 16:55:24 +01:00
Nicolas Meienberger
c2041932b5 feat: delete backup schedule 2025-11-02 16:52:25 +01:00
Nicolas Meienberger
44917f3513 fix(create-backup): missing submit button 2025-11-02 16:47:42 +01:00
Nicolas Meienberger
3e514e61db feat(backups): add backup card 2025-11-02 16:42:21 +01:00
Nicolas Meienberger
67e7d36fe7 refactor(ui): use dot for backup status 2025-11-02 16:36:02 +01:00
Nicolas Meienberger
8f447ac58d refactor: use correct namings 2025-11-02 16:05:22 +01:00
Nicolas Meienberger
3befa127d7 refactor: frontend components consolidation 2025-11-01 19:42:13 +01:00
Nicolas Meienberger
18115b374c feat(frontend): backup jobs page 2025-11-01 17:09:43 +01:00
Nicolas Meienberger
d81f3653ec feat: run backup now 2025-10-31 22:16:31 +01:00
Nicolas Meienberger
c64e50bdec chore: drizzle config root 2025-10-31 22:05:28 +01:00
Nicolas Meienberger
afeaf87bb0 refactor(utils): use nsenter only if /host/proc is mounted 2025-10-31 22:04:55 +01:00
Nicolas Meienberger
ee79fce2aa feat(frontend): restore whole snapshot 2025-10-31 21:52:54 +01:00
Nicolas Meienberger
c7db88fb56 feat(snapshot): backend restore api 2025-10-31 21:15:43 +01:00
Nicolas Meienberger
5846c1ff86 feat: snapshot files list frontend 2025-10-30 19:01:49 +01:00
Nicolas Meienberger
b80a187108 feat(snapshots): list files in snapshots api 2025-10-30 18:58:57 +01:00
Nicolas Meienberger
ed73ca73fb refactor(snapshots): unify tables in a single component 2025-10-30 18:38:05 +01:00
Nicolas Meienberger
bd168df352 style(layout): fix header sticky
style(layout): fix unnecessary scroll
2025-10-30 18:22:53 +01:00
Nicolas Meienberger
cce2d356fe feat: backup schedule frontend 2025-10-30 18:18:11 +01:00
Nicolas Meienberger
9628310d53 refactor(backups): use upsert instead of create/update split 2025-10-29 21:14:41 +01:00
Nicolas Meienberger
e335133237 fix(restic): unlock repo before trying to backup 2025-10-29 19:16:20 +01:00
Nicolas Meienberger
b188a84af3 refactor: simplify dtos and improve type saftey in json returns 2025-10-29 18:28:00 +01:00
Nicolas Meienberger
d1c1adaba7 feat: backup schedule creation form 2025-10-28 22:34:56 +01:00
Nicolas Meienberger
37a22b260f fix(login/onboarding): redirect to dashboard if already logged in 2025-10-25 22:46:37 +02:00
Nicolas Meienberger
a7bc1c2e7e refactor: bind /proc and mount volumes with nsenter 2025-10-25 22:30:33 +02:00
Nicolas Meienberger
43e31596f1 feat(backend): backup service with retention policy 2025-10-25 20:38:13 +02:00
Nicolas Meienberger
2202ad3247 refactor(backend): better job scheduling pattern 2025-10-25 17:46:01 +02:00
Nicolas Meienberger
47ff720adb style(auth): redesign login and onboarding pages 2025-10-25 17:12:43 +02:00
Nicolas Meienberger
d58c4f793d feat: enable restic cache 2025-10-23 21:10:36 +02:00
Nicolas Meienberger
f7718055eb feat: repositories snapshots frontend 2025-10-23 20:55:44 +02:00
Nicolas Meienberger
cae8538b2e feat(repositories): list snapshots api 2025-10-23 20:22:09 +02:00
Nicolas Meienberger
4ae738ce41 refactor: use schema constants 2025-10-23 19:25:12 +02:00
Nicolas Meienberger
8b1438ea62 feat: repositories frontend 2025-10-23 19:14:03 +02:00
Nicolas Meienberger
9d10e48da6 style: logo header 2025-10-22 22:16:29 +02:00
Nicolas Meienberger
a927411c0d style: full width design 2025-10-22 21:28:14 +02:00
Nicolas Meienberger
a64de8ec78 refactor: / -> /volumes 2025-10-21 21:03:38 +02:00
Nicolas Meienberger
6960b4d71e feat: sidebar 2025-10-21 20:45:20 +02:00
Nicolas Meienberger
06cb401cf7 fix(lifecycle): catch error on startup 2025-10-21 20:30:42 +02:00
Nicolas Meienberger
0090c3c43c refactor(schemas): move restic schemas to a subfolder 2025-10-21 19:57:04 +02:00
Nicolas Meienberger
e07c22a7d4 docs: add required licences 2025-10-21 19:56:59 +02:00
Nicolas Meienberger
100c24de13 feat: encrypt repository credentials at rest 2025-10-18 15:15:30 +02:00
Nicolas Meienberger
6e8aa4b465 feat: repositories controller and service for crd 2025-10-18 14:23:42 +02:00
Nicolas Meienberger
ad54948a69 refactor: use getVolumePath helper 2025-10-17 23:39:16 +02:00
Nicolas Meienberger
5b6a86331e fix(list): copy path to clipboard 2025-10-17 23:26:58 +02:00
Nicolas Meienberger
8fcc9ada74 feat: cleanup dangling volumes and folders on startup and on schedule 2025-10-17 23:01:47 +02:00
Nicolas Meienberger
8a9d5fc3c8 refactor(statfs): switch back to statfs but convert values for smb 2025-10-17 22:22:22 +02:00
Nicolas Meienberger
219dec1c9c fix(statfs): fix usage graph by using df command 2025-10-17 21:34:40 +02:00
Nicolas Meienberger
3bda6e81ae feat(details): keep tab in url to preserve active tab on reload 2025-10-17 21:23:33 +02:00
Nicolas Meienberger
269116c25e refactor: improve file explorer performance by pre-fetching on hover 2025-10-17 21:19:58 +02:00
Nicolas Meienberger
c8fc5a1273 chore: sanitize sensitive data from logs and error messages 2025-10-17 21:11:12 +02:00
123 changed files with 10667 additions and 1934 deletions

View File

@@ -18,3 +18,8 @@
!apps/**/public/**
!packages/**/src/**
# License files and attributions
!LICENSE
!NOTICES.md
!LICENSES/**

4
.gitignore vendored
View File

@@ -41,3 +41,7 @@ node_modules/
.turbo
mutagen.yml.lock
data/
CLAUDE.md

View File

@@ -1,18 +1,42 @@
ARG BUN_VERSION="1.3.0"
ARG BUN_VERSION="1.3.1"
FROM oven/bun:${BUN_VERSION}-alpine AS runner_base
FROM oven/bun:${BUN_VERSION}-alpine AS base
RUN apk add --no-cache davfs2 restic
RUN apk add --no-cache davfs2=1.6.1-r2
# ------------------------------
# DEPENDENCIES
# ------------------------------
FROM base AS deps
WORKDIR /deps
ARG TARGETARCH
ARG RESTIC_VERSION="0.18.1"
ENV TARGETARCH=${TARGETARCH}
RUN apk add --no-cache curl bzip2
RUN echo "Building for ${TARGETARCH}"
RUN if [ "${TARGETARCH}" = "arm64" ]; then \
curl -L -o restic.bz2 "https://github.com/restic/restic/releases/download/v$RESTIC_VERSION/restic_$RESTIC_VERSION"_linux_arm64.bz2; \
elif [ "${TARGETARCH}" = "amd64" ]; then \
curl -L -o restic.bz2 "https://github.com/restic/restic/releases/download/v$RESTIC_VERSION/restic_$RESTIC_VERSION"_linux_amd64.bz2; \
fi
RUN bzip2 -d restic.bz2 && chmod +x restic
# ------------------------------
# DEVELOPMENT
# ------------------------------
FROM runner_base AS development
FROM base AS development
ENV NODE_ENV="development"
WORKDIR /app
COPY --from=deps /deps/restic /usr/local/bin/restic
COPY ./package.json ./bun.lock ./
COPY ./packages/schemas/package.json ./packages/schemas/package.json
COPY ./apps/client/package.json ./apps/client/package.json
@@ -45,20 +69,21 @@ COPY . .
RUN bun run build
FROM runner_base AS production
FROM base AS production
ENV NODE_ENV="production"
# RUN bun i ssh2
RUN apk add --no-cache davfs2=1.6.1-r2
WORKDIR /app
COPY --from=deps /deps/restic /usr/local/bin/restic
COPY --from=builder /app/apps/server/dist ./
COPY --from=builder /app/apps/server/drizzle ./assets/migrations
COPY --from=builder /app/apps/client/dist/client ./assets/frontend
# Include third-party licenses and attribution
COPY ./LICENSES ./LICENSES
COPY ./NOTICES.md ./NOTICES.md
COPY ./LICENSE ./LICENSE.md
CMD ["bun", "./index.js"]

View File

@@ -0,0 +1,25 @@
BSD 2-Clause License
Copyright (c) 2014, Alexander Neumann <alexander@bumpern.de>
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

29
NOTICES.md Normal file
View File

@@ -0,0 +1,29 @@
# Third-Party Software Licenses and Attributions
This software includes the following third-party components that are subject to separate license terms:
## Restic
- **Version**: [Specify the version you're shipping]
- **Copyright**: Copyright (c) 2014, Alexander Neumann <alexander@bumpern.de>
- **License**: BSD 2-Clause License
- **Status**: Included unchanged
- **Source**: https://github.com/restic/restic
- **Full License Text**: See [LICENSES/BSD-2-Clause-Restic.txt](LICENSES/BSD-2-Clause-Restic.txt)
Restic is a backup program that is fast, secure, and efficient. It's included in this distribution as-is without any modifications.
---
## License Information
The main source code of this project is licensed under the GNU Affero General Public License v3. See the [LICENSE](LICENSE) file for details.
Each third-party component retains its original copyright and license. When distributing this software:
1. The original license of each component must be preserved
2. Copyright notices must be retained
3. A notice of changes (if any) must be provided for modified components
4. The original license file should be included with distributions
For more information about a specific component's license, refer to the corresponding license file in the [LICENSES/](LICENSES/) directory.

View File

@@ -42,11 +42,10 @@ In order to run Ironmount, you need to have Docker and Docker Compose installed
```yaml
services:
ironmount:
image: ghcr.io/nicotsx/ironmount:v0.2.0
image: ghcr.io/nicotsx/ironmount:v0.4.0
container_name: ironmount
restart: unless-stopped
cap_add:
- SYS_ADMIN
privileged: true
ports:
- "4096:4096"
devices:
@@ -54,7 +53,9 @@ services:
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /run/docker/plugins:/run/docker/plugins
- /var/lib/docker/volumes/:/var/lib/docker/volumes:rshared
- /var/lib/ironmount/volumes/:/var/lib/ironmount/volumes:rslave
- /var/lib/repositories/:/var/lib/repositories
- /proc:/host/proc:ro
- ironmount_data:/data
volumes:
@@ -73,3 +74,18 @@ Once the container is running, you can access the web interface at `http://<your
## Docker volume usage
![Preview](https://github.com/nicotsx/ironmount/blob/main/screenshots/docker-instructions.png?raw=true)
## Third-Party Software
This project includes the following third-party software components:
### Restic
Ironmount includes [Restic](https://github.com/restic/restic) for backup functionality.
- **License**: BSD 2-Clause License
- **Copyright**: Copyright (c) 2014, Alexander Neumann <alexander@bumpern.de>
- **Status**: Included unchanged
- **License Text**: See [LICENSES/BSD-2-Clause-Restic.txt](LICENSES/BSD-2-Clause-Restic.txt)
For a complete list of third-party software licenses and attributions, please refer to the [NOTICES.md](NOTICES.md) file.

View File

@@ -18,6 +18,21 @@ import {
unmountVolume,
healthCheckVolume,
listFiles,
listRepositories,
createRepository,
deleteRepository,
getRepository,
listSnapshots,
getSnapshotDetails,
listSnapshotFiles,
restoreSnapshot,
listBackupSchedules,
createBackupSchedule,
deleteBackupSchedule,
getBackupSchedule,
updateBackupSchedule,
getBackupScheduleForVolume,
runBackupNow,
} from "../sdk.gen";
import { queryOptions, type UseMutationOptions, type DefaultError } from "@tanstack/react-query";
import type {
@@ -47,6 +62,28 @@ import type {
HealthCheckVolumeData,
HealthCheckVolumeResponse,
ListFilesData,
ListRepositoriesData,
CreateRepositoryData,
CreateRepositoryResponse,
DeleteRepositoryData,
DeleteRepositoryResponse,
GetRepositoryData,
ListSnapshotsData,
GetSnapshotDetailsData,
ListSnapshotFilesData,
RestoreSnapshotData,
RestoreSnapshotResponse,
ListBackupSchedulesData,
CreateBackupScheduleData,
CreateBackupScheduleResponse,
DeleteBackupScheduleData,
DeleteBackupScheduleResponse,
GetBackupScheduleData,
UpdateBackupScheduleData,
UpdateBackupScheduleResponse,
GetBackupScheduleForVolumeData,
RunBackupNowData,
RunBackupNowResponse,
} from "../types.gen";
import { client as _heyApiClient } from "../client.gen";
@@ -561,3 +598,397 @@ export const listFilesOptions = (options: Options<ListFilesData>) => {
queryKey: listFilesQueryKey(options),
});
};
export const listRepositoriesQueryKey = (options?: Options<ListRepositoriesData>) =>
createQueryKey("listRepositories", options);
/**
* List all repositories
*/
export const listRepositoriesOptions = (options?: Options<ListRepositoriesData>) => {
return queryOptions({
queryFn: async ({ queryKey, signal }) => {
const { data } = await listRepositories({
...options,
...queryKey[0],
signal,
throwOnError: true,
});
return data;
},
queryKey: listRepositoriesQueryKey(options),
});
};
export const createRepositoryQueryKey = (options?: Options<CreateRepositoryData>) =>
createQueryKey("createRepository", options);
/**
* Create a new restic repository
*/
export const createRepositoryOptions = (options?: Options<CreateRepositoryData>) => {
return queryOptions({
queryFn: async ({ queryKey, signal }) => {
const { data } = await createRepository({
...options,
...queryKey[0],
signal,
throwOnError: true,
});
return data;
},
queryKey: createRepositoryQueryKey(options),
});
};
/**
* Create a new restic repository
*/
export const createRepositoryMutation = (
options?: Partial<Options<CreateRepositoryData>>,
): UseMutationOptions<CreateRepositoryResponse, DefaultError, Options<CreateRepositoryData>> => {
const mutationOptions: UseMutationOptions<CreateRepositoryResponse, DefaultError, Options<CreateRepositoryData>> = {
mutationFn: async (localOptions) => {
const { data } = await createRepository({
...options,
...localOptions,
throwOnError: true,
});
return data;
},
};
return mutationOptions;
};
/**
* Delete a repository
*/
export const deleteRepositoryMutation = (
options?: Partial<Options<DeleteRepositoryData>>,
): UseMutationOptions<DeleteRepositoryResponse, DefaultError, Options<DeleteRepositoryData>> => {
const mutationOptions: UseMutationOptions<DeleteRepositoryResponse, DefaultError, Options<DeleteRepositoryData>> = {
mutationFn: async (localOptions) => {
const { data } = await deleteRepository({
...options,
...localOptions,
throwOnError: true,
});
return data;
},
};
return mutationOptions;
};
export const getRepositoryQueryKey = (options: Options<GetRepositoryData>) => createQueryKey("getRepository", options);
/**
* Get a single repository by name
*/
export const getRepositoryOptions = (options: Options<GetRepositoryData>) => {
return queryOptions({
queryFn: async ({ queryKey, signal }) => {
const { data } = await getRepository({
...options,
...queryKey[0],
signal,
throwOnError: true,
});
return data;
},
queryKey: getRepositoryQueryKey(options),
});
};
export const listSnapshotsQueryKey = (options: Options<ListSnapshotsData>) => createQueryKey("listSnapshots", options);
/**
* List all snapshots in a repository
*/
export const listSnapshotsOptions = (options: Options<ListSnapshotsData>) => {
return queryOptions({
queryFn: async ({ queryKey, signal }) => {
const { data } = await listSnapshots({
...options,
...queryKey[0],
signal,
throwOnError: true,
});
return data;
},
queryKey: listSnapshotsQueryKey(options),
});
};
export const getSnapshotDetailsQueryKey = (options: Options<GetSnapshotDetailsData>) =>
createQueryKey("getSnapshotDetails", options);
/**
* Get details of a specific snapshot
*/
export const getSnapshotDetailsOptions = (options: Options<GetSnapshotDetailsData>) => {
return queryOptions({
queryFn: async ({ queryKey, signal }) => {
const { data } = await getSnapshotDetails({
...options,
...queryKey[0],
signal,
throwOnError: true,
});
return data;
},
queryKey: getSnapshotDetailsQueryKey(options),
});
};
export const listSnapshotFilesQueryKey = (options: Options<ListSnapshotFilesData>) =>
createQueryKey("listSnapshotFiles", options);
/**
* List files and directories in a snapshot
*/
export const listSnapshotFilesOptions = (options: Options<ListSnapshotFilesData>) => {
return queryOptions({
queryFn: async ({ queryKey, signal }) => {
const { data } = await listSnapshotFiles({
...options,
...queryKey[0],
signal,
throwOnError: true,
});
return data;
},
queryKey: listSnapshotFilesQueryKey(options),
});
};
export const restoreSnapshotQueryKey = (options: Options<RestoreSnapshotData>) =>
createQueryKey("restoreSnapshot", options);
/**
* Restore a snapshot to a target path on the filesystem
*/
export const restoreSnapshotOptions = (options: Options<RestoreSnapshotData>) => {
return queryOptions({
queryFn: async ({ queryKey, signal }) => {
const { data } = await restoreSnapshot({
...options,
...queryKey[0],
signal,
throwOnError: true,
});
return data;
},
queryKey: restoreSnapshotQueryKey(options),
});
};
/**
* Restore a snapshot to a target path on the filesystem
*/
export const restoreSnapshotMutation = (
options?: Partial<Options<RestoreSnapshotData>>,
): UseMutationOptions<RestoreSnapshotResponse, DefaultError, Options<RestoreSnapshotData>> => {
const mutationOptions: UseMutationOptions<RestoreSnapshotResponse, DefaultError, Options<RestoreSnapshotData>> = {
mutationFn: async (localOptions) => {
const { data } = await restoreSnapshot({
...options,
...localOptions,
throwOnError: true,
});
return data;
},
};
return mutationOptions;
};
export const listBackupSchedulesQueryKey = (options?: Options<ListBackupSchedulesData>) =>
createQueryKey("listBackupSchedules", options);
/**
* List all backup schedules
*/
export const listBackupSchedulesOptions = (options?: Options<ListBackupSchedulesData>) => {
return queryOptions({
queryFn: async ({ queryKey, signal }) => {
const { data } = await listBackupSchedules({
...options,
...queryKey[0],
signal,
throwOnError: true,
});
return data;
},
queryKey: listBackupSchedulesQueryKey(options),
});
};
export const createBackupScheduleQueryKey = (options?: Options<CreateBackupScheduleData>) =>
createQueryKey("createBackupSchedule", options);
/**
* Create a new backup schedule for a volume
*/
export const createBackupScheduleOptions = (options?: Options<CreateBackupScheduleData>) => {
return queryOptions({
queryFn: async ({ queryKey, signal }) => {
const { data } = await createBackupSchedule({
...options,
...queryKey[0],
signal,
throwOnError: true,
});
return data;
},
queryKey: createBackupScheduleQueryKey(options),
});
};
/**
* Create a new backup schedule for a volume
*/
export const createBackupScheduleMutation = (
options?: Partial<Options<CreateBackupScheduleData>>,
): UseMutationOptions<CreateBackupScheduleResponse, DefaultError, Options<CreateBackupScheduleData>> => {
const mutationOptions: UseMutationOptions<
CreateBackupScheduleResponse,
DefaultError,
Options<CreateBackupScheduleData>
> = {
mutationFn: async (localOptions) => {
const { data } = await createBackupSchedule({
...options,
...localOptions,
throwOnError: true,
});
return data;
},
};
return mutationOptions;
};
/**
* Delete a backup schedule
*/
export const deleteBackupScheduleMutation = (
options?: Partial<Options<DeleteBackupScheduleData>>,
): UseMutationOptions<DeleteBackupScheduleResponse, DefaultError, Options<DeleteBackupScheduleData>> => {
const mutationOptions: UseMutationOptions<
DeleteBackupScheduleResponse,
DefaultError,
Options<DeleteBackupScheduleData>
> = {
mutationFn: async (localOptions) => {
const { data } = await deleteBackupSchedule({
...options,
...localOptions,
throwOnError: true,
});
return data;
},
};
return mutationOptions;
};
export const getBackupScheduleQueryKey = (options: Options<GetBackupScheduleData>) =>
createQueryKey("getBackupSchedule", options);
/**
* Get a backup schedule by ID
*/
export const getBackupScheduleOptions = (options: Options<GetBackupScheduleData>) => {
return queryOptions({
queryFn: async ({ queryKey, signal }) => {
const { data } = await getBackupSchedule({
...options,
...queryKey[0],
signal,
throwOnError: true,
});
return data;
},
queryKey: getBackupScheduleQueryKey(options),
});
};
/**
* Update a backup schedule
*/
export const updateBackupScheduleMutation = (
options?: Partial<Options<UpdateBackupScheduleData>>,
): UseMutationOptions<UpdateBackupScheduleResponse, DefaultError, Options<UpdateBackupScheduleData>> => {
const mutationOptions: UseMutationOptions<
UpdateBackupScheduleResponse,
DefaultError,
Options<UpdateBackupScheduleData>
> = {
mutationFn: async (localOptions) => {
const { data } = await updateBackupSchedule({
...options,
...localOptions,
throwOnError: true,
});
return data;
},
};
return mutationOptions;
};
export const getBackupScheduleForVolumeQueryKey = (options: Options<GetBackupScheduleForVolumeData>) =>
createQueryKey("getBackupScheduleForVolume", options);
/**
* Get a backup schedule for a specific volume
*/
export const getBackupScheduleForVolumeOptions = (options: Options<GetBackupScheduleForVolumeData>) => {
return queryOptions({
queryFn: async ({ queryKey, signal }) => {
const { data } = await getBackupScheduleForVolume({
...options,
...queryKey[0],
signal,
throwOnError: true,
});
return data;
},
queryKey: getBackupScheduleForVolumeQueryKey(options),
});
};
export const runBackupNowQueryKey = (options: Options<RunBackupNowData>) => createQueryKey("runBackupNow", options);
/**
* Trigger a backup immediately for a schedule
*/
export const runBackupNowOptions = (options: Options<RunBackupNowData>) => {
return queryOptions({
queryFn: async ({ queryKey, signal }) => {
const { data } = await runBackupNow({
...options,
...queryKey[0],
signal,
throwOnError: true,
});
return data;
},
queryKey: runBackupNowQueryKey(options),
});
};
/**
* Trigger a backup immediately for a schedule
*/
export const runBackupNowMutation = (
options?: Partial<Options<RunBackupNowData>>,
): UseMutationOptions<RunBackupNowResponse, DefaultError, Options<RunBackupNowData>> => {
const mutationOptions: UseMutationOptions<RunBackupNowResponse, DefaultError, Options<RunBackupNowData>> = {
mutationFn: async (localOptions) => {
const { data } = await runBackupNow({
...options,
...localOptions,
throwOnError: true,
});
return data;
},
};
return mutationOptions;
};

View File

@@ -17,6 +17,6 @@ export type CreateClientConfig<T extends DefaultClientOptions = ClientOptions> =
export const client = createClient(
createConfig<ClientOptions>({
baseUrl: "http://localhost:4096",
baseUrl: "http://192.168.2.42:4096",
}),
);

View File

@@ -12,7 +12,6 @@ import type {
LogoutResponses,
GetMeData,
GetMeResponses,
GetMeErrors,
GetStatusData,
GetStatusResponses,
ListVolumesData,
@@ -34,16 +33,43 @@ import type {
GetContainersUsingVolumeErrors,
MountVolumeData,
MountVolumeResponses,
MountVolumeErrors,
UnmountVolumeData,
UnmountVolumeResponses,
UnmountVolumeErrors,
HealthCheckVolumeData,
HealthCheckVolumeResponses,
HealthCheckVolumeErrors,
ListFilesData,
ListFilesResponses,
ListFilesErrors,
ListRepositoriesData,
ListRepositoriesResponses,
CreateRepositoryData,
CreateRepositoryResponses,
DeleteRepositoryData,
DeleteRepositoryResponses,
GetRepositoryData,
GetRepositoryResponses,
ListSnapshotsData,
ListSnapshotsResponses,
GetSnapshotDetailsData,
GetSnapshotDetailsResponses,
ListSnapshotFilesData,
ListSnapshotFilesResponses,
RestoreSnapshotData,
RestoreSnapshotResponses,
ListBackupSchedulesData,
ListBackupSchedulesResponses,
CreateBackupScheduleData,
CreateBackupScheduleResponses,
DeleteBackupScheduleData,
DeleteBackupScheduleResponses,
GetBackupScheduleData,
GetBackupScheduleResponses,
UpdateBackupScheduleData,
UpdateBackupScheduleResponses,
GetBackupScheduleForVolumeData,
GetBackupScheduleForVolumeResponses,
RunBackupNowData,
RunBackupNowResponses,
} from "./types.gen";
import { client as _heyApiClient } from "./client.gen";
@@ -106,7 +132,7 @@ export const logout = <ThrowOnError extends boolean = false>(options?: Options<L
* Get current authenticated user
*/
export const getMe = <ThrowOnError extends boolean = false>(options?: Options<GetMeData, ThrowOnError>) => {
return (options?.client ?? _heyApiClient).get<GetMeResponses, GetMeErrors, ThrowOnError>({
return (options?.client ?? _heyApiClient).get<GetMeResponses, unknown, ThrowOnError>({
url: "/api/v1/auth/me",
...options,
});
@@ -222,7 +248,7 @@ export const getContainersUsingVolume = <ThrowOnError extends boolean = false>(
* Mount a volume
*/
export const mountVolume = <ThrowOnError extends boolean = false>(options: Options<MountVolumeData, ThrowOnError>) => {
return (options.client ?? _heyApiClient).post<MountVolumeResponses, MountVolumeErrors, ThrowOnError>({
return (options.client ?? _heyApiClient).post<MountVolumeResponses, unknown, ThrowOnError>({
url: "/api/v1/volumes/{name}/mount",
...options,
});
@@ -234,7 +260,7 @@ export const mountVolume = <ThrowOnError extends boolean = false>(options: Optio
export const unmountVolume = <ThrowOnError extends boolean = false>(
options: Options<UnmountVolumeData, ThrowOnError>,
) => {
return (options.client ?? _heyApiClient).post<UnmountVolumeResponses, UnmountVolumeErrors, ThrowOnError>({
return (options.client ?? _heyApiClient).post<UnmountVolumeResponses, unknown, ThrowOnError>({
url: "/api/v1/volumes/{name}/unmount",
...options,
});
@@ -256,8 +282,204 @@ export const healthCheckVolume = <ThrowOnError extends boolean = false>(
* List files in a volume directory
*/
export const listFiles = <ThrowOnError extends boolean = false>(options: Options<ListFilesData, ThrowOnError>) => {
return (options.client ?? _heyApiClient).get<ListFilesResponses, ListFilesErrors, ThrowOnError>({
return (options.client ?? _heyApiClient).get<ListFilesResponses, unknown, ThrowOnError>({
url: "/api/v1/volumes/{name}/files",
...options,
});
};
/**
* List all repositories
*/
export const listRepositories = <ThrowOnError extends boolean = false>(
options?: Options<ListRepositoriesData, ThrowOnError>,
) => {
return (options?.client ?? _heyApiClient).get<ListRepositoriesResponses, unknown, ThrowOnError>({
url: "/api/v1/repositories",
...options,
});
};
/**
* Create a new restic repository
*/
export const createRepository = <ThrowOnError extends boolean = false>(
options?: Options<CreateRepositoryData, ThrowOnError>,
) => {
return (options?.client ?? _heyApiClient).post<CreateRepositoryResponses, unknown, ThrowOnError>({
url: "/api/v1/repositories",
...options,
headers: {
"Content-Type": "application/json",
...options?.headers,
},
});
};
/**
* Delete a repository
*/
export const deleteRepository = <ThrowOnError extends boolean = false>(
options: Options<DeleteRepositoryData, ThrowOnError>,
) => {
return (options.client ?? _heyApiClient).delete<DeleteRepositoryResponses, unknown, ThrowOnError>({
url: "/api/v1/repositories/{name}",
...options,
});
};
/**
* Get a single repository by name
*/
export const getRepository = <ThrowOnError extends boolean = false>(
options: Options<GetRepositoryData, ThrowOnError>,
) => {
return (options.client ?? _heyApiClient).get<GetRepositoryResponses, unknown, ThrowOnError>({
url: "/api/v1/repositories/{name}",
...options,
});
};
/**
* List all snapshots in a repository
*/
export const listSnapshots = <ThrowOnError extends boolean = false>(
options: Options<ListSnapshotsData, ThrowOnError>,
) => {
return (options.client ?? _heyApiClient).get<ListSnapshotsResponses, unknown, ThrowOnError>({
url: "/api/v1/repositories/{name}/snapshots",
...options,
});
};
/**
* Get details of a specific snapshot
*/
export const getSnapshotDetails = <ThrowOnError extends boolean = false>(
options: Options<GetSnapshotDetailsData, ThrowOnError>,
) => {
return (options.client ?? _heyApiClient).get<GetSnapshotDetailsResponses, unknown, ThrowOnError>({
url: "/api/v1/repositories/{name}/snapshots/{snapshotId}",
...options,
});
};
/**
* List files and directories in a snapshot
*/
export const listSnapshotFiles = <ThrowOnError extends boolean = false>(
options: Options<ListSnapshotFilesData, ThrowOnError>,
) => {
return (options.client ?? _heyApiClient).get<ListSnapshotFilesResponses, unknown, ThrowOnError>({
url: "/api/v1/repositories/{name}/snapshots/{snapshotId}/files",
...options,
});
};
/**
* Restore a snapshot to a target path on the filesystem
*/
export const restoreSnapshot = <ThrowOnError extends boolean = false>(
options: Options<RestoreSnapshotData, ThrowOnError>,
) => {
return (options.client ?? _heyApiClient).post<RestoreSnapshotResponses, unknown, ThrowOnError>({
url: "/api/v1/repositories/{name}/restore",
...options,
headers: {
"Content-Type": "application/json",
...options.headers,
},
});
};
/**
* List all backup schedules
*/
export const listBackupSchedules = <ThrowOnError extends boolean = false>(
options?: Options<ListBackupSchedulesData, ThrowOnError>,
) => {
return (options?.client ?? _heyApiClient).get<ListBackupSchedulesResponses, unknown, ThrowOnError>({
url: "/api/v1/backups",
...options,
});
};
/**
* Create a new backup schedule for a volume
*/
export const createBackupSchedule = <ThrowOnError extends boolean = false>(
options?: Options<CreateBackupScheduleData, ThrowOnError>,
) => {
return (options?.client ?? _heyApiClient).post<CreateBackupScheduleResponses, unknown, ThrowOnError>({
url: "/api/v1/backups",
...options,
headers: {
"Content-Type": "application/json",
...options?.headers,
},
});
};
/**
* Delete a backup schedule
*/
export const deleteBackupSchedule = <ThrowOnError extends boolean = false>(
options: Options<DeleteBackupScheduleData, ThrowOnError>,
) => {
return (options.client ?? _heyApiClient).delete<DeleteBackupScheduleResponses, unknown, ThrowOnError>({
url: "/api/v1/backups/{scheduleId}",
...options,
});
};
/**
* Get a backup schedule by ID
*/
export const getBackupSchedule = <ThrowOnError extends boolean = false>(
options: Options<GetBackupScheduleData, ThrowOnError>,
) => {
return (options.client ?? _heyApiClient).get<GetBackupScheduleResponses, unknown, ThrowOnError>({
url: "/api/v1/backups/{scheduleId}",
...options,
});
};
/**
* Update a backup schedule
*/
export const updateBackupSchedule = <ThrowOnError extends boolean = false>(
options: Options<UpdateBackupScheduleData, ThrowOnError>,
) => {
return (options.client ?? _heyApiClient).patch<UpdateBackupScheduleResponses, unknown, ThrowOnError>({
url: "/api/v1/backups/{scheduleId}",
...options,
headers: {
"Content-Type": "application/json",
...options.headers,
},
});
};
/**
* Get a backup schedule for a specific volume
*/
export const getBackupScheduleForVolume = <ThrowOnError extends boolean = false>(
options: Options<GetBackupScheduleForVolumeData, ThrowOnError>,
) => {
return (options.client ?? _heyApiClient).get<GetBackupScheduleForVolumeResponses, unknown, ThrowOnError>({
url: "/api/v1/backups/volume/{volumeId}",
...options,
});
};
/**
* Trigger a backup immediately for a schedule
*/
export const runBackupNow = <ThrowOnError extends boolean = false>(
options: Options<RunBackupNowData, ThrowOnError>,
) => {
return (options.client ?? _heyApiClient).post<RunBackupNowResponses, unknown, ThrowOnError>({
url: "/api/v1/backups/{scheduleId}/run",
...options,
});
};

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,6 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "dither-plugin";
@custom-variant dark (&:is(.dark *));
@@ -15,12 +16,19 @@ body {
overflow-x: hidden;
width: 100%;
position: relative;
overscroll-behavior: none;
scrollbar-width: thin;
@media (prefers-color-scheme: dark) {
color-scheme: dark;
}
}
.main-content {
scrollbar-width: thin;
scrollbar-gutter: stable;
}
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);

View File

@@ -8,7 +8,6 @@ import {
BreadcrumbSeparator,
} from "~/components/ui/breadcrumb";
import { useBreadcrumbs } from "~/lib/breadcrumbs";
import { cn } from "../lib/utils";
export function AppBreadcrumb() {
const breadcrumbs = useBreadcrumbs();
@@ -17,17 +16,12 @@ export function AppBreadcrumb() {
<Breadcrumb>
<BreadcrumbLink asChild></BreadcrumbLink>
<BreadcrumbList>
{breadcrumbs.length === 1 && (
<BreadcrumbItem>
<Link to="/">Ironmount</Link>
</BreadcrumbItem>
)}
{breadcrumbs.map((breadcrumb, index) => {
const isLast = index === breadcrumbs.length - 1;
return (
<div key={`${breadcrumb.label}-${index}`} className="contents">
<BreadcrumbItem className={cn({ invisible: breadcrumbs.length <= 1 })}>
<BreadcrumbItem>
{isLast || breadcrumb.isCurrentPage ? (
<BreadcrumbPage>{breadcrumb.label}</BreadcrumbPage>
) : breadcrumb.href ? (

View File

@@ -0,0 +1,85 @@
import { CalendarClock, Database, HardDrive, Mountain } from "lucide-react";
import { Link, NavLink } from "react-router";
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "~/components/ui/sidebar";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "~/components/ui/tooltip";
import { cn } from "~/lib/utils";
const items = [
{
title: "Volumes",
url: "/volumes",
icon: HardDrive,
},
{
title: "Repositories",
url: "/repositories",
icon: Database,
},
{
title: "Backups",
url: "/backups",
icon: CalendarClock,
},
];
export function AppSidebar() {
const { state } = useSidebar();
return (
<Sidebar variant="inset" collapsible="icon" className="p-0">
<SidebarHeader className="bg-card-header border-b border-border/50 hidden md:flex h-[65px] flex-row items-center p-4">
<Link to="/volumes" className="flex items-center gap-3 font-semibold pl-2">
<Mountain className="size-5 text-strong-accent" />
<span
className={cn("text-base transition-all duration-200", {
"opacity-0 w-0 overflow-hidden ": state === "collapsed",
})}
>
Ironmount
</span>
</Link>
</SidebarHeader>
<SidebarContent className="p-2 border-r">
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.title}>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<SidebarMenuButton asChild>
<NavLink to={item.url}>
{({ isActive }) => (
<>
<item.icon className={cn({ "text-strong-accent": isActive })} />
<span className={cn({ "text-strong-accent": isActive })}>{item.title}</span>
</>
)}
</NavLink>
</SidebarMenuButton>
</TooltipTrigger>
<TooltipContent side="right" className={cn({ hidden: state !== "collapsed" })}>
<p>{item.title}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
</Sidebar>
);
}

View File

@@ -0,0 +1,34 @@
import { Mountain } from "lucide-react";
import type { ReactNode } from "react";
type AuthLayoutProps = {
title: string;
description: string;
children: ReactNode;
};
export function AuthLayout({ title, description, children }: AuthLayoutProps) {
return (
<div className="flex min-h-screen">
<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="flex items-center gap-3">
<Mountain className="size-5 text-strong-accent" />
<span className="text-lg font-semibold">Ironmount</span>
</div>
<div className="space-y-2">
<h1 className="text-3xl font-bold tracking-tight">{title}</h1>
<p className="text-sm text-muted-foreground">{description}</p>
</div>
{children}
</div>
</div>
<div
className="hidden lg:block lg:flex-1 dither-xl bg-cover bg-center"
style={{ backgroundImage: "url(/background.jpg)" }}
/>
</div>
);
}

View File

@@ -1,4 +1,4 @@
import React from "react";
import type React from "react";
type ByteSizeProps = {
bytes: number;
@@ -54,7 +54,7 @@ export function formatBytes(
idx = Math.max(0, Math.min(idx, units.length - 1));
}
const numeric = (abs / Math.pow(base, idx)) * sign;
const numeric = (abs / base ** idx) * sign;
const maxFrac = (() => {
if (!smartRounding) return maximumFractionDigits;

View File

@@ -0,0 +1,66 @@
import { useMutation } from "@tanstack/react-query";
import { Plus } from "lucide-react";
import { useId } from "react";
import { toast } from "sonner";
import { createRepositoryMutation } from "~/api-client/@tanstack/react-query.gen";
import { parseError } from "~/lib/errors";
import { CreateRepositoryForm } from "./create-repository-form";
import { Button } from "./ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "./ui/dialog";
import { ScrollArea } from "./ui/scroll-area";
type Props = {
open: boolean;
setOpen: (open: boolean) => void;
};
export const CreateRepositoryDialog = ({ open, setOpen }: Props) => {
const formId = useId();
const create = useMutation({
...createRepositoryMutation(),
onSuccess: () => {
toast.success("Repository created successfully");
setOpen(false);
},
onError: (error) => {
toast.error("Failed to create repository", {
description: parseError(error)?.message,
});
},
});
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button>
<Plus size={16} className="mr-2" />
Create Repository
</Button>
</DialogTrigger>
<DialogContent>
<ScrollArea className="h-[500px] p-4">
<DialogHeader>
<DialogTitle>Create repository</DialogTitle>
</DialogHeader>
<CreateRepositoryForm
className="mt-4"
mode="create"
formId={formId}
onSubmit={(values) => {
create.mutate({ body: { config: values, name: values.name, compressionMode: values.compressionMode } });
}}
/>
<DialogFooter className="mt-4">
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button type="submit" form={formId} disabled={create.isPending}>
Create
</Button>
</DialogFooter>
</ScrollArea>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,206 @@
import { arktypeResolver } from "@hookform/resolvers/arktype";
import { COMPRESSION_MODES, repositoryConfigSchema } from "@ironmount/schemas/restic";
import { type } from "arktype";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { cn, slugify } from "~/lib/utils";
import { deepClean } from "~/utils/object";
import { Button } from "./ui/button";
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "./ui/form";
import { Input } from "./ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
export const formSchema = type({
name: "2<=string<=32",
compressionMode: type.valueOf(COMPRESSION_MODES).optional(),
}).and(repositoryConfigSchema);
const cleanSchema = type.pipe((d) => formSchema(deepClean(d)));
export type RepositoryFormValues = typeof formSchema.inferIn;
type Props = {
onSubmit: (values: RepositoryFormValues) => void;
mode?: "create" | "update";
initialValues?: Partial<RepositoryFormValues>;
formId?: string;
loading?: boolean;
className?: string;
};
const defaultValuesForType = {
local: { backend: "local" as const, compressionMode: "auto" as const },
s3: { backend: "s3" as const, compressionMode: "auto" as const },
};
export const CreateRepositoryForm = ({
onSubmit,
mode = "create",
initialValues,
formId,
loading,
className,
}: Props) => {
const form = useForm<RepositoryFormValues>({
resolver: arktypeResolver(cleanSchema as unknown as typeof formSchema),
defaultValues: initialValues,
resetOptions: {
keepDefaultValues: true,
keepDirtyValues: false,
},
});
const { watch } = form;
const watchedBackend = watch("backend");
const watchedName = watch("name");
useEffect(() => {
if (watchedBackend && watchedBackend in defaultValuesForType) {
form.reset({ name: watchedName, ...defaultValuesForType[watchedBackend as keyof typeof defaultValuesForType] });
}
}, [watchedBackend, watchedName, form]);
return (
<Form {...form}>
<form id={formId} onSubmit={form.handleSubmit(onSubmit)} className={cn("space-y-4", className)}>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input
{...field}
placeholder="Repository name"
onChange={(e) => field.onChange(slugify(e.target.value))}
max={32}
min={2}
disabled={mode === "update"}
className={mode === "update" ? "bg-gray-50" : ""}
/>
</FormControl>
<FormDescription>Unique identifier for the repository.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="backend"
render={({ field }) => (
<FormItem>
<FormLabel>Backend</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a backend" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="local">Local</SelectItem>
<SelectItem value="s3">S3</SelectItem>
</SelectContent>
</Select>
<FormDescription>Choose the storage backend for this repository.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="compressionMode"
render={({ field }) => (
<FormItem>
<FormLabel>Compression Mode</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select compression mode" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="off">Off</SelectItem>
<SelectItem value="auto">Auto</SelectItem>
<SelectItem value="fastest">Fastest</SelectItem>
<SelectItem value="better">Better</SelectItem>
<SelectItem value="max">Max</SelectItem>
</SelectContent>
</Select>
<FormDescription>Compression mode for backups stored in this repository.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{watchedBackend === "s3" && (
<>
<FormField
control={form.control}
name="endpoint"
render={({ field }) => (
<FormItem>
<FormLabel>Endpoint</FormLabel>
<FormControl>
<Input placeholder="s3.amazonaws.com" {...field} />
</FormControl>
<FormDescription>S3-compatible endpoint URL.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="bucket"
render={({ field }) => (
<FormItem>
<FormLabel>Bucket</FormLabel>
<FormControl>
<Input placeholder="my-backup-bucket" {...field} />
</FormControl>
<FormDescription>S3 bucket name for storing backups.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="accessKeyId"
render={({ field }) => (
<FormItem>
<FormLabel>Access Key ID</FormLabel>
<FormControl>
<Input placeholder="AKIAIOSFODNN7EXAMPLE" {...field} />
</FormControl>
<FormDescription>S3 access key ID for authentication.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="secretAccessKey"
render={({ field }) => (
<FormItem>
<FormLabel>Secret Access Key</FormLabel>
<FormControl>
<Input type="password" placeholder="••••••••" {...field} />
</FormControl>
<FormDescription>S3 secret access key for authentication.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{mode === "update" && (
<Button type="submit" className="w-full" loading={loading}>
Save Changes
</Button>
)}
</form>
</Form>
);
};

View File

@@ -1,56 +1,32 @@
import { Database, HardDrive, HeartPulse, Plus } from "lucide-react";
import { CreateVolumeDialog } from "./create-volume-dialog";
import { useState } from "react";
import { Card } from "./ui/card";
export function EmptyState() {
const [createVolumeOpen, setCreateVolumeOpen] = useState(false);
type EmptyStateProps = {
title?: string;
description?: string;
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
button?: React.ReactNode;
};
export function EmptyState(props: EmptyStateProps) {
const { title, description, icon: Cicon, button } = props;
return (
<div className="flex flex-col items-center justify-center py-16 px-4 text-center">
<div className="relative mb-8">
<div className="absolute inset-0 animate-pulse">
<div className="w-32 h-32 rounded-full bg-primary/10 blur-2xl" />
</div>
<div className="relative flex items-center justify-center w-32 h-32 rounded-full bg-gradient-to-br from-primary/20 to-primary/5 border-2 border-primary/20">
<Database className="w-16 h-16 text-primary/70" strokeWidth={1.5} />
</div>
</div>
<div className="max-w-md space-y-3 mb-8">
<h3 className="text-2xl font-semibold text-foreground">No volumes yet</h3>
<p className="text-muted-foreground">
Get started by creating your first volume. Manage and monitor all your storage backends in one place with
advanced features like automatic mounting and health checks.
</p>
</div>
<CreateVolumeDialog open={createVolumeOpen} setOpen={setCreateVolumeOpen} />
<div className="mt-12 grid grid-cols-1 md:grid-cols-3 gap-0 max-w-3xl">
<div className="flex flex-col items-center gap-2 p-4 border bg-card-header">
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center">
<Database className="w-5 h-5 text-primary" />
<Card className="p-0 gap-0">
<div className="flex flex-col items-center justify-center py-16 px-4 text-center">
<div className="relative mb-8">
<div className="absolute inset-0 animate-pulse">
<div className="w-32 h-32 rounded-full bg-primary/10 blur-2xl" />
</div>
<h4 className="font-medium text-sm">Multiple Backends</h4>
<p className="text-xs text-muted-foreground">Support for local, NFS, and SMB storage</p>
</div>
<div className="flex flex-col items-center gap-2 p-4 border border-r-0 border-l-0 bg-card-header">
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center">
<HardDrive className="w-5 h-5 text-primary" />
<div className="relative flex items-center justify-center w-32 h-32 rounded-full bg-gradient-to-br from-primary/20 to-primary/5 border-2 border-primary/20">
<Cicon className="w-16 h-16 text-primary/70" strokeWidth={1.5} />
</div>
<h4 className="font-medium text-sm">Auto Mounting</h4>
<p className="text-xs text-muted-foreground">Automatic lifecycle management</p>
</div>
<div className="flex flex-col items-center gap-2 p-4 border bg-card-header">
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center">
<HeartPulse className="w-5 h-5 text-primary" />
</div>
<h4 className="font-medium text-sm">Real-time Monitoring</h4>
<p className="text-xs text-muted-foreground">Live status and health checks</p>
<div className="max-w-md space-y-3 mb-8">
<h3 className="text-2xl font-semibold text-foreground">{title}</h3>
<p className="text-muted-foreground text-sm">{description}</p>
</div>
{button}
</div>
</div>
</Card>
);
}

View File

@@ -11,13 +11,14 @@
import { ChevronDown, ChevronRight, File as FileIcon, Folder as FolderIcon, FolderOpen, Loader2 } from "lucide-react";
import { memo, type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
import { cn } from "~/lib/utils";
import { Checkbox } from "~/components/ui/checkbox";
const NODE_PADDING_LEFT = 12;
interface FileEntry {
export interface FileEntry {
name: string;
path: string;
type: "file" | "directory";
type: string;
size?: number;
modifiedAt?: number;
}
@@ -27,9 +28,14 @@ interface Props {
selectedFile?: string;
onFileSelect?: (filePath: string) => void;
onFolderExpand?: (folderPath: string) => void;
onFolderHover?: (folderPath: string) => void;
expandedFolders?: Set<string>;
loadingFolders?: Set<string>;
className?: string;
withCheckboxes?: boolean;
selectedPaths?: Set<string>;
onSelectionChange?: (selectedPaths: Set<string>) => void;
foldersOnly?: boolean;
}
export const FileTree = memo((props: Props) => {
@@ -38,14 +44,19 @@ export const FileTree = memo((props: Props) => {
onFileSelect,
selectedFile,
onFolderExpand,
onFolderHover,
expandedFolders = new Set(),
loadingFolders = new Set(),
className,
withCheckboxes = false,
selectedPaths = new Set(),
onSelectionChange,
foldersOnly = false,
} = props;
const fileList = useMemo(() => {
return buildFileList(files);
}, [files]);
return buildFileList(files, foldersOnly);
}, [files, foldersOnly]);
const [collapsedFolders, setCollapsedFolders] = useState<Set<string>>(new Set());
@@ -115,6 +126,149 @@ export const FileTree = memo((props: Props) => {
[onFileSelect],
);
const handleSelectionChange = useCallback(
(path: string, checked: boolean) => {
const newSelection = new Set(selectedPaths);
if (checked) {
// Add the path itself
newSelection.add(path);
// Remove any descendants from selection since parent now covers them
for (const item of fileList) {
if (item.fullPath.startsWith(`${path}/`)) {
newSelection.delete(item.fullPath);
}
}
} else {
// Remove the path itself
newSelection.delete(path);
// Check if any parent is selected - if so, we need to add siblings back
const pathSegments = path.split("/").filter(Boolean);
let parentIsSelected = false;
let selectedParentPath = "";
// Check each parent level to see if any are selected
for (let i = pathSegments.length - 1; i > 0; i--) {
const parentPath = `/${pathSegments.slice(0, i).join("/")}`;
if (newSelection.has(parentPath)) {
parentIsSelected = true;
selectedParentPath = parentPath;
break;
}
}
if (parentIsSelected) {
// Remove the selected parent
newSelection.delete(selectedParentPath);
// Add all siblings and descendants of the selected parent, except the unchecked path and its descendants
for (const item of fileList) {
if (
item.fullPath.startsWith(`${selectedParentPath}/`) &&
!item.fullPath.startsWith(`${path}/`) &&
item.fullPath !== path
) {
newSelection.add(item.fullPath);
}
}
}
}
const childrenByParent = new Map<string, string[]>();
for (const selectedPath of newSelection) {
const lastSlashIndex = selectedPath.lastIndexOf("/");
if (lastSlashIndex > 0) {
const parentPath = selectedPath.slice(0, lastSlashIndex);
if (!childrenByParent.has(parentPath)) {
childrenByParent.set(parentPath, []);
}
childrenByParent.get(parentPath)?.push(selectedPath);
}
}
// For each parent, check if all its children are selected
for (const [parentPath, selectedChildren] of childrenByParent.entries()) {
// Get all children of this parent from the file list
const allChildren = fileList.filter((item) => {
const itemParentPath = item.fullPath.slice(0, item.fullPath.lastIndexOf("/"));
return itemParentPath === parentPath;
});
// If all children are selected, replace them with the parent
if (allChildren.length > 0 && selectedChildren.length === allChildren.length) {
// Check that we have every child
const allChildrenPaths = new Set(allChildren.map((c) => c.fullPath));
const allChildrenSelected = selectedChildren.every((c) => allChildrenPaths.has(c));
if (allChildrenSelected) {
// Remove all children
for (const childPath of selectedChildren) {
newSelection.delete(childPath);
}
// Add the parent
newSelection.add(parentPath);
}
}
}
onSelectionChange?.(newSelection);
},
[selectedPaths, onSelectionChange, fileList],
);
// Helper to check if a path is selected (either directly or via parent)
const isPathSelected = useCallback(
(path: string): boolean => {
// Check if directly selected
if (selectedPaths.has(path)) {
return true;
}
// Check if any parent is selected
const pathSegments = path.split("/").filter(Boolean);
for (let i = pathSegments.length - 1; i > 0; i--) {
const parentPath = `/${pathSegments.slice(0, i).join("/")}`;
if (selectedPaths.has(parentPath)) {
return true;
}
}
return false;
},
[selectedPaths],
);
// Determine if a folder is partially selected (some children selected)
const isPartiallySelected = useCallback(
(folderPath: string): boolean => {
// If the folder itself is selected, it's not partial
if (selectedPaths.has(folderPath)) {
return false;
}
// Check if this folder is implicitly selected via a parent
const pathSegments = folderPath.split("/").filter(Boolean);
for (let i = pathSegments.length - 1; i > 0; i--) {
const parentPath = `/${pathSegments.slice(0, i).join("/")}`;
if (selectedPaths.has(parentPath)) {
// Parent is selected, so this folder is fully selected, not partial
return false;
}
}
for (const selectedPath of selectedPaths) {
if (selectedPath.startsWith(`${folderPath}/`)) {
return true;
}
}
return false;
},
[selectedPaths],
);
return (
<div className={cn("text-sm", className)}>
{filteredFileList.map((fileOrFolder) => {
@@ -126,6 +280,9 @@ export const FileTree = memo((props: Props) => {
selected={selectedFile === fileOrFolder.fullPath}
file={fileOrFolder}
onFileSelect={handleFileSelect}
withCheckbox={withCheckboxes}
checked={isPathSelected(fileOrFolder.fullPath)}
onCheckboxChange={handleSelectionChange}
/>
);
}
@@ -137,6 +294,11 @@ export const FileTree = memo((props: Props) => {
collapsed={collapsedFolders.has(fileOrFolder.fullPath)}
loading={loadingFolders.has(fileOrFolder.fullPath)}
onToggle={toggleCollapseState}
onHover={onFolderHover}
withCheckbox={withCheckboxes}
checked={isPathSelected(fileOrFolder.fullPath) && !isPartiallySelected(fileOrFolder.fullPath)}
partiallyChecked={isPartiallySelected(fileOrFolder.fullPath)}
onCheckboxChange={handleSelectionChange}
/>
);
}
@@ -154,53 +316,104 @@ interface FolderProps {
collapsed: boolean;
loading?: boolean;
onToggle: (fullPath: string) => void;
onHover?: (fullPath: string) => void;
withCheckbox?: boolean;
checked?: boolean;
partiallyChecked?: boolean;
onCheckboxChange?: (path: string, checked: boolean) => void;
}
const Folder = memo(({ folder, collapsed, loading, onToggle }: FolderProps) => {
const { depth, name, fullPath } = folder;
const FolderIconComponent = collapsed ? FolderIcon : FolderOpen;
const Folder = memo(
({
folder,
collapsed,
loading,
onToggle,
onHover,
withCheckbox,
checked,
partiallyChecked,
onCheckboxChange,
}: FolderProps) => {
const { depth, name, fullPath } = folder;
const FolderIconComponent = collapsed ? FolderIcon : FolderOpen;
const handleClick = useCallback(() => {
onToggle(fullPath);
}, [onToggle, fullPath]);
const handleChevronClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
onToggle(fullPath);
},
[onToggle, fullPath],
);
return (
<NodeButton
className={cn("group hover:bg-accent/50 text-foreground")}
depth={depth}
icon={
loading ? (
<Loader2 className="w-4 h-4 shrink-0 animate-spin" />
) : collapsed ? (
<ChevronRight className="w-4 h-4 shrink-0" />
) : (
<ChevronDown className="w-4 h-4 shrink-0" />
)
const handleMouseEnter = useCallback(() => {
if (collapsed) {
onHover?.(fullPath);
}
onClick={handleClick}
>
<FolderIconComponent className="w-4 h-4 shrink-0 text-strong-accent" />
<span className="truncate">{name}</span>
</NodeButton>
);
});
}, [onHover, fullPath, collapsed]);
const handleCheckboxChange = useCallback(
(value: boolean) => {
onCheckboxChange?.(fullPath, value);
},
[onCheckboxChange, fullPath],
);
return (
<NodeButton
className={cn("group hover:bg-accent/50 text-foreground")}
depth={depth}
icon={
loading ? (
<Loader2 className="w-4 h-4 shrink-0 animate-spin" />
) : collapsed ? (
<ChevronRight className="w-4 h-4 shrink-0 cursor-pointer" onClick={handleChevronClick} />
) : (
<ChevronDown className="w-4 h-4 shrink-0 cursor-pointer" onClick={handleChevronClick} />
)
}
onMouseEnter={handleMouseEnter}
>
{withCheckbox && (
<Checkbox
checked={checked ? true : partiallyChecked ? "indeterminate" : false}
onCheckedChange={handleCheckboxChange}
onClick={(e) => e.stopPropagation()}
/>
)}
<FolderIconComponent className="w-4 h-4 shrink-0 text-strong-accent" />
<span className="truncate">{name}</span>
</NodeButton>
);
},
);
interface FileProps {
file: FileNode;
selected: boolean;
onFileSelect: (filePath: string) => void;
withCheckbox?: boolean;
checked?: boolean;
onCheckboxChange?: (path: string, checked: boolean) => void;
}
const File = memo(({ file, onFileSelect, selected }: FileProps) => {
const File = memo(({ file, onFileSelect, selected, withCheckbox, checked, onCheckboxChange }: FileProps) => {
const { depth, name, fullPath } = file;
const handleClick = useCallback(() => {
onFileSelect(fullPath);
}, [onFileSelect, fullPath]);
const handleCheckboxChange = useCallback(
(value: boolean) => {
onCheckboxChange?.(fullPath, value);
},
[onCheckboxChange, fullPath],
);
return (
<NodeButton
className={cn("group", {
className={cn("group cursor-pointer", {
"hover:bg-accent/50 text-foreground": !selected,
"bg-accent text-accent-foreground": selected,
})}
@@ -208,6 +421,9 @@ const File = memo(({ file, onFileSelect, selected }: FileProps) => {
icon={<FileIcon className="w-4 h-4 shrink-0 text-gray-500" />}
onClick={handleClick}
>
{withCheckbox && (
<Checkbox checked={checked} onCheckedChange={handleCheckboxChange} onClick={(e) => e.stopPropagation()} />
)}
<span className="truncate">{name}</span>
</NodeButton>
);
@@ -219,9 +435,10 @@ interface ButtonProps {
children: ReactNode;
className?: string;
onClick?: () => void;
onMouseEnter?: () => void;
}
const NodeButton = memo(({ depth, icon, onClick, className, children }: ButtonProps) => {
const NodeButton = memo(({ depth, icon, onClick, onMouseEnter, className, children }: ButtonProps) => {
const paddingLeft = useMemo(() => `${8 + depth * NODE_PADDING_LEFT}px`, [depth]);
return (
@@ -230,6 +447,7 @@ const NodeButton = memo(({ depth, icon, onClick, className, children }: ButtonPr
className={cn("flex items-center gap-2 w-full pr-2 text-sm py-1.5 text-left", className)}
style={{ paddingLeft }}
onClick={onClick}
onMouseEnter={onMouseEnter}
>
{icon}
<div className="truncate w-full flex items-center gap-2">{children}</div>
@@ -254,10 +472,14 @@ interface FolderNode extends BaseNode {
kind: "folder";
}
function buildFileList(files: FileEntry[]): Node[] {
function buildFileList(files: FileEntry[], foldersOnly = false): Node[] {
const fileMap = new Map<string, Node>();
for (const file of files) {
if (foldersOnly && file.type === "file") {
continue;
}
const segments = file.path.split("/").filter((segment) => segment);
const depth = segments.length - 1;
const name = segments[segments.length - 1];

View File

@@ -11,15 +11,14 @@ export function GridBackground({ children, className, containerClassName }: Grid
return (
<div
className={cn(
"relative min-h-dvh w-full overflow-x-hidden",
"relative min-h-full w-full overflow-x-hidden",
"[background-size:20px_20px] sm:[background-size:40px_40px]",
"[background-image:linear-gradient(to_right,#e4e4e7_1px,transparent_1px),linear-gradient(to_bottom,#e4e4e7_1px,transparent_1px)]",
"dark:[background-image:linear-gradient(to_right,#262626_1px,transparent_1px),linear-gradient(to_bottom,#262626_1px,transparent_1px)]",
containerClassName,
)}
>
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-white [mask-image:radial-gradient(ellipse_at_center,transparent_20%,black)] dark:bg-card" />
<div className={cn("relative h-screen", className)}>{children}</div>
<div className={cn("relative container m-auto", className)}>{children}</div>
</div>
);
}

View File

@@ -9,6 +9,8 @@ import type { Route } from "./+types/layout";
import { AppBreadcrumb } from "./app-breadcrumb";
import { GridBackground } from "./grid-background";
import { Button } from "./ui/button";
import { SidebarProvider, SidebarTrigger } from "./ui/sidebar";
import { AppSidebar } from "./app-sidebar";
export const clientMiddleware = [authMiddleware];
@@ -27,43 +29,54 @@ export default function Layout({ loaderData }: Route.ComponentProps) {
},
onError: (error) => {
console.error(error);
toast.error("Logout failed");
toast.error("Logout failed", { description: error.message });
},
});
return (
<GridBackground>
<header className="bg-card-header border-b border-border/50">
<div className="flex items-center justify-between py-3 sm:py-4 px-2 sm:px-4 container mx-auto">
<AppBreadcrumb />
{loaderData.user && (
<SidebarProvider defaultOpen={true}>
<AppSidebar />
<div className="w-full relative flex flex-col h-screen overflow-hidden">
<header className="z-50 bg-card-header border-b border-border/50 flex-shrink-0">
<div className="flex items-center justify-between py-3 sm:py-4 px-2 sm:px-8 mx-auto container">
<div className="flex items-center gap-4">
<span className="text-sm text-muted-foreground">
Welcome, <span className="text-strong-accent">{loaderData.user?.username}</span>
</span>
<Button variant="outline" size="sm" onClick={() => logout.mutate({})} loading={logout.isPending}>
Logout
</Button>
<Button variant="default" size="sm" className="relative overflow-hidden">
<a
href="https://github.com/nicotsx/ironmount/issues/new"
target="_blank"
rel="noreferrer"
className="flex items-center gap-2"
>
<span className="flex items-center gap-2">
<LifeBuoy />
<span>Report an issue</span>
</span>
</a>
</Button>
<SidebarTrigger />
<AppBreadcrumb />
</div>
)}
{loaderData.user && (
<div className="flex items-center gap-4">
<span className="text-sm text-muted-foreground hidden md:inline-flex">
Welcome,&nbsp;
<span className="text-strong-accent">{loaderData.user?.username}</span>
</span>
<Button variant="default" size="sm" onClick={() => logout.mutate({})} loading={logout.isPending}>
Logout
</Button>
<Button variant="default" size="sm" className="relative overflow-hidden hidden lg:inline-flex">
<a
href="https://github.com/nicotsx/ironmount/issues/new"
target="_blank"
rel="noreferrer"
className="flex items-center gap-2"
>
<span className="flex items-center gap-2">
<LifeBuoy />
<span>Report an issue</span>
</span>
</a>
</Button>
</div>
)}
</div>
</header>
<div className="main-content flex-1 overflow-y-auto">
<GridBackground>
<main className="flex flex-col p-2 pt-2 sm:p-8 sm:pt-6 mx-auto">
<Outlet />
</main>
</GridBackground>
</div>
</header>
<main className="flex flex-col pt-4 sm:pt-8 px-2 sm:px-4 pb-4 container mx-auto">
<Outlet />
</main>
</GridBackground>
</div>
</SidebarProvider>
);
}

View File

@@ -0,0 +1,18 @@
import type { RepositoryBackend } from "@ironmount/schemas/restic";
import { Database, HardDrive, Cloud } from "lucide-react";
type Props = {
backend: RepositoryBackend;
className?: string;
};
export const RepositoryIcon = ({ backend, className = "h-4 w-4" }: Props) => {
switch (backend) {
case "local":
return <HardDrive className={className} />;
case "s3":
return <Cloud className={className} />;
default:
return <Database className={className} />;
}
};

View File

@@ -0,0 +1,97 @@
import { Calendar, Clock, Database, FolderTree, HardDrive } from "lucide-react";
import { useNavigate } from "react-router";
import type { ListSnapshotsResponse } from "~/api-client/types.gen";
import { ByteSize } from "~/components/bytes-size";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/components/ui/table";
import { Tooltip, TooltipContent, TooltipTrigger } from "~/components/ui/tooltip";
import { formatSnapshotDuration } from "~/modules/repositories/tabs/snapshots";
type Snapshot = ListSnapshotsResponse[number];
type Props = {
snapshots: Snapshot[];
repositoryName: string;
};
export const SnapshotsTable = ({ snapshots, repositoryName }: Props) => {
const navigate = useNavigate();
const handleRowClick = (snapshotId: string) => {
navigate(`/repositories/${repositoryName}/${snapshotId}`);
};
return (
<div className="overflow-x-auto">
<Table className="border-t">
<TableHeader className="bg-card-header">
<TableRow>
<TableHead className="uppercase">Snapshot ID</TableHead>
<TableHead className="uppercase">Date & Time</TableHead>
<TableHead className="uppercase">Size</TableHead>
<TableHead className="uppercase hidden md:table-cell text-right">Duration</TableHead>
<TableHead className="uppercase hidden text-right lg:table-cell">Paths</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{snapshots.map((snapshot) => (
<TableRow
key={snapshot.short_id}
className="hover:bg-accent/50 cursor-pointer"
onClick={() => handleRowClick(snapshot.short_id)}
>
<TableCell className="font-mono text-sm">
<div className="flex items-center gap-2">
<HardDrive className="h-4 w-4 text-muted-foreground" />
<span className="text-strong-accent">{snapshot.short_id}</span>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Calendar className="h-4 w-4 text-muted-foreground" />
<span className="text-sm">{new Date(snapshot.time).toLocaleString()}</span>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Database className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">
<ByteSize bytes={snapshot.size} base={1024} />
</span>
</div>
</TableCell>
<TableCell className="hidden md:table-cell">
<div className="flex items-center justify-end gap-2">
<Clock className="h-4 w-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">
{formatSnapshotDuration(snapshot.duration / 1000)}
</span>
</div>
</TableCell>
<TableCell className="hidden lg:table-cell">
<div className="flex items-center justify-end gap-2">
<FolderTree className="h-4 w-4 text-muted-foreground" />
<Tooltip>
<TooltipTrigger asChild>
<span className="text-xs bg-primary/10 text-primary rounded-md px-2 py-1 cursor-help">
{snapshot.paths.length} {snapshot.paths.length === 1 ? "path" : "paths"}
</span>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-md">
<div className="flex flex-col gap-1">
{snapshot.paths.map((path) => (
<div key={`${snapshot.short_id}-${path}`} className="text-xs font-mono">
{path}
</div>
))}
</div>
</TooltipContent>
</Tooltip>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
};

View File

@@ -0,0 +1,36 @@
import type * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "~/lib/utils";
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default: "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary: "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline: "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
);
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> & VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span";
return <Comp data-slot="badge" className={cn(badgeVariants({ variant }), className)} {...props} />;
}
export { Badge, badgeVariants };

View File

@@ -38,6 +38,7 @@ function Button({
variant,
size,
asChild = false,
loading,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
@@ -47,13 +48,13 @@ function Button({
return (
<Comp
disabled={props.loading}
disabled={loading}
data-slot="button"
className={cn(buttonVariants({ variant, size, className }), "transition-all")}
{...props}
>
<Loader2 className={cn("h-4 w-4 animate-spin absolute", { invisible: !props.loading })} />
<div className={cn("flex items-center justify-center", { invisible: props.loading })}>{props.children}</div>
<Loader2 className={cn("h-4 w-4 animate-spin absolute", { invisible: !loading })} />
<div className={cn("flex items-center justify-center", { invisible: loading })}>{props.children}</div>
</Comp>
);
}

View File

@@ -1,355 +1,298 @@
import * as React from "react"
import * as RechartsPrimitive from "recharts"
// @ts-nocheck
// biome-ignore-all lint: reason
import * as React from "react";
import * as RechartsPrimitive from "recharts";
import { cn } from "~/lib/utils"
import { cn } from "~/lib/utils";
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const
const THEMES = { light: "", dark: ".dark" } as const;
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode
icon?: React.ComponentType
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
)
}
[k in string]: {
label?: React.ReactNode;
icon?: React.ComponentType;
} & ({ color?: string; theme?: never } | { color?: never; theme: Record<keyof typeof THEMES, string> });
};
type ChartContextProps = {
config: ChartConfig
}
config: ChartConfig;
};
const ChartContext = React.createContext<ChartContextProps | null>(null)
const ChartContext = React.createContext<ChartContextProps | null>(null);
function useChart() {
const context = React.useContext(ChartContext)
const context = React.useContext(ChartContext);
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />")
}
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />");
}
return context
return context;
}
function ChartContainer({
id,
className,
children,
config,
...props
id,
className,
children,
config,
...props
}: React.ComponentProps<"div"> & {
config: ChartConfig
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"]
config: ChartConfig;
children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>["children"];
}) {
const uniqueId = React.useId()
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
const uniqueId = React.useId();
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
return (
<ChartContext.Provider value={{ config }}>
<div
data-slot="chart"
data-chart={chartId}
className={cn(
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
)
return (
<ChartContext.Provider value={{ config }}>
<div
data-slot="chart"
data-chart={chartId}
className={cn(
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className,
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>{children}</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
);
}
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color
)
const colorConfig = Object.entries(config).filter(([, config]) => config.theme || config.color);
if (!colorConfig.length) {
return null
}
if (!colorConfig.length) {
return null;
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
)
.map(([key, itemConfig]) => {
const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.color;
return color ? ` --color-${key}: ${color};` : null;
})
.join("\n")}
}
`,
)
.join("\n"),
}}
/>
);
};
const ChartTooltip = RechartsPrimitive.Tooltip
const ChartTooltip = RechartsPrimitive.Tooltip;
function ChartTooltipContent({
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
}) {
const { config } = useChart()
React.ComponentProps<"div"> & {
hideLabel?: boolean;
hideIndicator?: boolean;
indicator?: "line" | "dot" | "dashed";
nameKey?: string;
labelKey?: string;
}) {
const { config } = useChart();
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null
}
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null;
}
const [item] = payload
const key = `${labelKey || item?.dataKey || item?.name || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label
const [item] = payload;
const key = `${labelKey || item?.dataKey || item?.name || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const value =
!labelKey && typeof label === "string" ? config[label as keyof typeof config]?.label || label : itemConfig?.label;
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
)
}
if (labelFormatter) {
return <div className={cn("font-medium", labelClassName)}>{labelFormatter(value, payload)}</div>;
}
if (!value) {
return null
}
if (!value) {
return null;
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
])
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
}, [label, labelFormatter, payload, hideLabel, labelClassName, config, labelKey]);
if (!active || !payload?.length) {
return null
}
if (!active || !payload?.length) {
return null;
}
const nestLabel = payload.length === 1 && indicator !== "dot"
const nestLabel = payload.length === 1 && indicator !== "dot";
return (
<div
className={cn(
"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload
.filter((item) => item.type !== "none")
.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color || item.payload.fill || item.color
return (
<div
className={cn(
"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
className,
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload
.filter((item) => item.type !== "none")
.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor = color || item.payload.fill || item.color;
return (
<div
key={item.dataKey}
className={cn(
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
indicator === "dot" && "items-center"
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="text-foreground font-mono font-medium tabular-nums">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
)
})}
</div>
</div>
)
return (
<div
key={item.dataKey}
className={cn(
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
indicator === "dot" && "items-center",
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn("shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)", {
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent": indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
})}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center",
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">{itemConfig?.label || item.name}</span>
</div>
{item.value && (
<span className="text-foreground font-mono font-medium tabular-nums">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
);
})}
</div>
</div>
);
}
const ChartLegend = RechartsPrimitive.Legend
const ChartLegend = RechartsPrimitive.Legend;
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = "bottom",
nameKey,
className,
hideIcon = false,
payload,
verticalAlign = "bottom",
nameKey,
}: React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean
nameKey?: string
}) {
const { config } = useChart()
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean;
nameKey?: string;
}) {
const { config } = useChart();
if (!payload?.length) {
return null
}
if (!payload?.length) {
return null;
}
return (
<div
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload
.filter((item) => item.type !== "none")
.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
<div className={cn("flex items-center justify-center gap-4", verticalAlign === "top" ? "pb-3" : "pt-3", className)}>
{payload
.filter((item) => item.type !== "none")
.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
return (
<div
key={item.value}
className={cn(
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3"
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
)
})}
</div>
)
return (
<div
key={item.value}
className={cn("[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3")}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
);
})}
</div>
);
}
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string
) {
if (typeof payload !== "object" || payload === null) {
return undefined
}
function getPayloadConfigFromPayload(config: ChartConfig, payload: unknown, key: string) {
if (typeof payload !== "object" || payload === null) {
return undefined;
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined
const payloadPayload =
"payload" in payload && typeof payload.payload === "object" && payload.payload !== null
? payload.payload
: undefined;
let configLabelKey: string = key
let configLabelKey: string = key;
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string
}
if (key in payload && typeof payload[key as keyof typeof payload] === "string") {
configLabelKey = payload[key as keyof typeof payload] as string;
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[key as keyof typeof payloadPayload] as string;
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config]
return configLabelKey in config ? config[configLabelKey] : config[key as keyof typeof config];
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
}
export { ChartContainer, ChartTooltip, ChartTooltipContent, ChartLegend, ChartLegendContent, ChartStyle };

View File

@@ -0,0 +1,30 @@
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { CheckIcon } from "lucide-react"
import { cn } from "~/lib/utils"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }

View File

@@ -0,0 +1,26 @@
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "~/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }

View File

@@ -0,0 +1,139 @@
"use client"
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "~/lib/utils"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className
)}
{...props}
>
{children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View File

@@ -0,0 +1,677 @@
"use client";
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { PanelLeftIcon } from "lucide-react";
import { useIsMobile } from "~/hooks/use-mobile";
import { cn } from "~/lib/utils";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { Separator } from "~/components/ui/separator";
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "~/components/ui/sheet";
import { Skeleton } from "~/components/ui/skeleton";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "~/components/ui/tooltip";
const SIDEBAR_COOKIE_NAME = "sidebar_state";
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH = "14rem";
const SIDEBAR_WIDTH_MOBILE = "18rem";
const SIDEBAR_WIDTH_ICON = "3rem";
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
type SidebarContextProps = {
state: "expanded" | "collapsed";
open: boolean;
setOpen: (open: boolean) => void;
openMobile: boolean;
setOpenMobile: (open: boolean) => void;
isMobile: boolean;
toggleSidebar: () => void;
};
const SidebarContext = React.createContext<SidebarContextProps | null>(null);
function useSidebar() {
const context = React.useContext(SidebarContext);
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.");
}
return context;
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: React.ComponentProps<"div"> & {
defaultOpen?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}) {
const isMobile = useIsMobile();
const [openMobile, setOpenMobile] = React.useState(false);
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen);
const open = openProp ?? _open;
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value;
if (setOpenProp) {
setOpenProp(openState);
} else {
_setOpen(openState);
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
},
[setOpenProp, open],
);
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
}, [isMobile, setOpen, setOpenMobile]);
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
toggleSidebar();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [toggleSidebar]);
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed";
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar],
);
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn("group/sidebar-wrapper flex min-h-svh w-full", className)}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
);
}
function Sidebar({
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
...props
}: React.ComponentProps<"div"> & {
side?: "left" | "right";
variant?: "sidebar" | "floating" | "inset";
collapsible?: "offcanvas" | "icon" | "none";
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
if (collapsible === "none") {
return (
<div
data-slot="sidebar"
className={cn("bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col", className)}
{...props}
>
{children}
</div>
);
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
);
}
return (
<div
className="group peer text-sidebar-foreground hidden md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-in-out",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)",
)}
/>
<div
data-slot="sidebar-container"
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-in-out md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className,
)}
{...props}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
>
{children}
</div>
</div>
</div>
);
}
function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar();
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon"
className={cn("size-7", className)}
onClick={(event) => {
onClick?.(event);
toggleSidebar();
}}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
);
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar();
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-in-out group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className,
)}
{...props}
/>
);
}
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
return (
<main
data-slot="sidebar-inset"
className={cn(
"bg-background relative flex w-full flex-1 flex-col",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className,
)}
{...props}
/>
);
}
function SidebarInput({ className, ...props }: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn("bg-background h-8 w-full shadow-none", className)}
{...props}
/>
);
}
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
);
}
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
);
}
function SidebarSeparator({ className, ...props }: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn("bg-sidebar-border mx-2 w-auto", className)}
{...props}
/>
);
}
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className,
)}
{...props}
/>
);
}
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
);
}
function SidebarGroupLabel({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "div";
return (
<Comp
data-slot="sidebar-group-label"
data-sidebar="group-label"
className={cn(
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-in-out focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className,
)}
{...props}
/>
);
}
function SidebarGroupAction({
className,
asChild = false,
...props
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="sidebar-group-action"
data-sidebar="group-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
}
function SidebarGroupContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
);
}
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
);
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
);
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function SidebarMenuButton({
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean;
isActive?: boolean;
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot : "button";
const { isMobile, state } = useSidebar();
const button = (
<Comp
data-slot="sidebar-menu-button"
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
);
if (!tooltip) {
return button;
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
};
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent side="right" align="center" hidden={state !== "collapsed" || isMobile} {...tooltip} />
</Tooltip>
);
}
function SidebarMenuAction({
className,
asChild = false,
showOnHover = false,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean;
showOnHover?: boolean;
}) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
className,
)}
{...props}
/>
);
}
function SidebarMenuBadge({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<"div"> & {
showIcon?: boolean;
}) {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`;
}, []);
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && <Skeleton className="size-4 rounded-md" data-sidebar="menu-skeleton-icon" />}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
);
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
}
function SidebarMenuSubItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn("group/menu-sub-item relative", className)}
{...props}
/>
);
}
function SidebarMenuSubButton({
asChild = false,
size = "md",
isActive = false,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean;
size?: "sm" | "md";
isActive?: boolean;
}) {
const Comp = asChild ? Slot : "a";
return (
<Comp
data-slot="sidebar-menu-sub-button"
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
};

View File

@@ -0,0 +1,13 @@
import { cn } from "~/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("bg-accent animate-pulse rounded-md", className)}
{...props}
/>
)
}
export { Skeleton }

View File

@@ -0,0 +1,165 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { FolderOpen } from "lucide-react";
import { useCallback, useMemo, useState } from "react";
import { listFilesOptions } from "~/api-client/@tanstack/react-query.gen";
import { FileTree } from "~/components/file-tree";
interface FileEntry {
name: string;
path: string;
type: "file" | "directory";
size?: number;
modifiedAt?: number;
}
type VolumeFileBrowserProps = {
volumeName: string;
enabled?: boolean;
refetchInterval?: number | false;
withCheckboxes?: boolean;
selectedPaths?: Set<string>;
onSelectionChange?: (paths: Set<string>) => void;
foldersOnly?: boolean;
className?: string;
emptyMessage?: string;
emptyDescription?: string;
};
export const VolumeFileBrowser = ({
volumeName,
enabled = true,
refetchInterval,
withCheckboxes = false,
selectedPaths,
onSelectionChange,
foldersOnly = false,
className,
emptyMessage = "This volume appears to be empty.",
emptyDescription,
}: VolumeFileBrowserProps) => {
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, error } = useQuery({
...listFilesOptions({ path: { name: volumeName } }),
enabled,
refetchInterval,
});
useMemo(() => {
if (data?.files) {
setAllFiles((prev) => {
const next = new Map(prev);
for (const file of data.files) {
next.set(file.path, file);
}
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(
listFilesOptions({
path: { name: volumeName },
query: { path: folderPath },
}),
);
if (result.files) {
setAllFiles((prev) => {
const next = new Map(prev);
for (const file of result.files) {
next.set(file.path, file);
}
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;
});
}
}
},
[volumeName, fetchedFolders, queryClient.fetchQuery],
);
const handleFolderHover = useCallback(
(folderPath: string) => {
if (!fetchedFolders.has(folderPath) && !loadingFolders.has(folderPath)) {
queryClient.prefetchQuery(
listFilesOptions({
path: { name: volumeName },
query: { path: folderPath },
}),
);
}
},
[volumeName, fetchedFolders, loadingFolders, queryClient],
);
if (isLoading && fileArray.length === 0) {
return (
<div className="flex items-center justify-center h-full min-h-[200px]">
<p className="text-muted-foreground">Loading files...</p>
</div>
);
}
if (error) {
return (
<div className="flex items-center justify-center h-full min-h-[200px]">
<p className="text-destructive">Failed to load files: {(error as Error).message}</p>
</div>
);
}
if (fileArray.length === 0) {
return (
<div className="flex flex-col items-center justify-center h-full text-center p-8 min-h-[200px]">
<FolderOpen className="mb-4 h-12 w-12 text-muted-foreground" />
<p className="text-muted-foreground">{emptyMessage}</p>
{emptyDescription && <p className="text-sm text-muted-foreground mt-2">{emptyDescription}</p>}
</div>
);
}
return (
<div className={className}>
<FileTree
files={fileArray}
onFolderExpand={handleFolderExpand}
onFolderHover={handleFolderHover}
expandedFolders={expandedFolders}
loadingFolders={loadingFolders}
withCheckboxes={withCheckboxes}
selectedPaths={selectedPaths}
onSelectionChange={onSelectionChange}
foldersOnly={foldersOnly}
/>
</div>
);
};

View File

@@ -0,0 +1,19 @@
import * as React from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
return !!isMobile
}

View File

@@ -15,14 +15,56 @@ export interface BreadcrumbItem {
export function generateBreadcrumbs(pathname: string, params: Record<string, string | undefined>): BreadcrumbItem[] {
const breadcrumbs: BreadcrumbItem[] = [];
// Always start with Home
if (pathname.startsWith("/repositories")) {
breadcrumbs.push({
label: "Repositories",
href: "/repositories",
isCurrentPage: pathname === "/repositories",
});
if (pathname.startsWith("/repositories/") && params.name) {
const isSnapshotPage = !!params.snapshotId;
breadcrumbs.push({
label: params.name,
href: isSnapshotPage ? `/repositories/${params.name}` : undefined,
isCurrentPage: !isSnapshotPage,
});
if (isSnapshotPage && params.snapshotId) {
breadcrumbs.push({
label: params.snapshotId,
isCurrentPage: true,
});
}
}
return breadcrumbs;
}
if (pathname.startsWith("/backups")) {
breadcrumbs.push({
label: "Backups",
href: "/backups",
isCurrentPage: pathname === "/backups",
});
if (pathname.startsWith("/backups/") && params.id) {
breadcrumbs.push({
label: `Schedule #${params.id}`,
isCurrentPage: true,
});
}
return breadcrumbs;
}
breadcrumbs.push({
label: "Volumes",
href: "/",
isCurrentPage: pathname === "/",
href: "/volumes",
isCurrentPage: pathname === "/volumes",
});
// Handle volume details page
if (pathname.startsWith("/volumes/") && params.name) {
breadcrumbs.push({
label: params.name,

View File

@@ -1,7 +1,19 @@
import type { GetMeResponse, GetVolumeResponse } from "~/api-client";
import type {
GetBackupScheduleResponse,
GetMeResponse,
GetRepositoryResponse,
GetVolumeResponse,
ListSnapshotsResponse,
} from "~/api-client";
export type Volume = GetVolumeResponse["volume"];
export type StatFs = GetVolumeResponse["statfs"];
export type VolumeStatus = Volume["status"];
export type User = GetMeResponse["user"];
export type Repository = GetRepositoryResponse;
export type BackupSchedule = GetBackupScheduleResponse;
export type Snapshot = ListSnapshotsResponse[number];

View File

@@ -2,10 +2,12 @@ import { redirect, type MiddlewareFunction } from "react-router";
import { getMe, getStatus } from "~/api-client";
import { appContext } from "~/context";
export const authMiddleware: MiddlewareFunction = async ({ context }) => {
export const authMiddleware: MiddlewareFunction = async ({ context, request }) => {
const session = await getMe();
if (!session.data?.user.id) {
const isAuthRoute = ["/login", "/onboarding"].includes(new URL(request.url).pathname);
if (!session.data?.user?.id && !isAuthRoute) {
const status = await getStatus();
if (!status.data?.hasUsers) {
throw redirect("/onboarding");
@@ -14,5 +16,11 @@ export const authMiddleware: MiddlewareFunction = async ({ context }) => {
throw redirect("/login");
}
context.set(appContext, { user: session.data.user, hasUsers: true });
if (session.data?.user?.id) {
context.set(appContext, { user: session.data.user, hasUsers: true });
if (isAuthRoute) {
throw redirect("/");
}
}
};

View File

@@ -0,0 +1,100 @@
import { arktypeResolver } from "@hookform/resolvers/arktype";
import { useMutation } from "@tanstack/react-query";
import { type } from "arktype";
import { useForm } from "react-hook-form";
import { useNavigate } from "react-router";
import { toast } from "sonner";
import { loginMutation } from "~/api-client/@tanstack/react-query.gen";
import { AuthLayout } from "~/components/auth-layout";
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";
export const clientMiddleware = [authMiddleware];
const loginSchema = type({
username: "2<=string<=50",
password: "string>=1",
});
type LoginFormValues = typeof loginSchema.inferIn;
export default function LoginPage() {
const navigate = useNavigate();
const form = useForm<LoginFormValues>({
resolver: arktypeResolver(loginSchema),
defaultValues: {
username: "",
password: "",
},
});
const login = useMutation({
...loginMutation(),
onSuccess: async () => {
navigate("/volumes");
},
onError: (error) => {
console.error(error);
toast.error("Login failed", { description: error.message });
},
});
const onSubmit = (values: LoginFormValues) => {
login.mutate({
body: {
username: values.username.trim(),
password: values.password.trim(),
},
});
};
return (
<AuthLayout title="Login to your account" description="Enter your credentials below to login to your account">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input {...field} type="text" placeholder="admin" disabled={login.isPending} autoFocus />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<div className="flex items-center justify-between">
<FormLabel>Password</FormLabel>
<button
type="button"
className="text-xs text-muted-foreground hover:underline"
onClick={() => toast.info("Password reset not implemented")}
>
Forgot your password?
</button>
</div>
<FormControl>
<Input {...field} type="password" disabled={login.isPending} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="w-full" loading={login.isPending}>
Login
</Button>
</form>
</Form>
</AuthLayout>
);
}

View File

@@ -0,0 +1,127 @@
import { arktypeResolver } from "@hookform/resolvers/arktype";
import { useMutation } from "@tanstack/react-query";
import { type } from "arktype";
import { useForm } from "react-hook-form";
import { useNavigate } from "react-router";
import { toast } from "sonner";
import { registerMutation } from "~/api-client/@tanstack/react-query.gen";
import { AuthLayout } from "~/components/auth-layout";
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";
export const clientMiddleware = [authMiddleware];
const onboardingSchema = type({
username: "2<=string<=50",
password: "string>=8",
confirmPassword: "string>=1",
});
type OnboardingFormValues = typeof onboardingSchema.inferIn;
export default function OnboardingPage() {
const navigate = useNavigate();
const form = useForm<OnboardingFormValues>({
resolver: arktypeResolver(onboardingSchema),
defaultValues: {
username: "",
password: "",
confirmPassword: "",
},
});
const registerUser = useMutation({
...registerMutation(),
onSuccess: async () => {
toast.success("Admin user created successfully!");
navigate("/volumes");
},
onError: (error) => {
console.error(error);
toast.error("Failed to create admin user", { description: error.message });
},
});
const onSubmit = (values: OnboardingFormValues) => {
if (values.password !== values.confirmPassword) {
form.setError("confirmPassword", {
type: "manual",
message: "Passwords do not match",
});
return;
}
registerUser.mutate({
body: {
username: values.username.trim(),
password: values.password.trim(),
},
});
};
return (
<AuthLayout title="Welcome to Ironmount" description="Create the admin user to get started">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input {...field} type="text" placeholder="admin" disabled={registerUser.isPending} autoFocus />
</FormControl>
<FormDescription>Choose a username for the admin account</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input
{...field}
type="password"
placeholder="Enter a secure password"
disabled={registerUser.isPending}
/>
</FormControl>
<FormDescription>Password must be at least 8 characters long.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="confirmPassword"
render={({ field }) => (
<FormItem>
<FormLabel>Confirm Password</FormLabel>
<FormControl>
<Input
{...field}
type="password"
placeholder="Re-enter your password"
disabled={registerUser.isPending}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="w-full" loading={registerUser.isPending}>
Create Admin User
</Button>
</form>
</Form>
</AuthLayout>
);
}

View File

@@ -0,0 +1,55 @@
import { cn } from "~/lib/utils";
import { Tooltip, TooltipContent, TooltipTrigger } from "~/components/ui/tooltip";
type BackupStatus = "active" | "paused" | "error";
export const BackupStatusDot = ({ enabled, hasError }: { enabled: boolean; hasError?: boolean }) => {
let status: BackupStatus = "paused";
if (hasError) {
status = "error";
} else if (enabled) {
status = "active";
}
const statusMapping = {
active: {
color: "bg-green-500",
colorLight: "bg-emerald-400",
animated: true,
label: "Active",
},
paused: {
color: "bg-gray-500",
colorLight: "bg-gray-400",
animated: false,
label: "Paused",
},
error: {
color: "bg-red-500",
colorLight: "bg-red-400",
animated: true,
label: "Error",
},
}[status];
return (
<Tooltip>
<TooltipTrigger>
<span className="relative flex size-3 mx-auto">
{statusMapping.animated && (
<span
className={cn(
"absolute inline-flex h-full w-full animate-ping rounded-full opacity-75",
`${statusMapping.colorLight}`,
)}
/>
)}
<span className={cn("relative inline-flex size-3 rounded-full", `${statusMapping.color}`)} />
</span>
</TooltipTrigger>
<TooltipContent>
<p>{statusMapping.label}</p>
</TooltipContent>
</Tooltip>
);
};

View File

@@ -0,0 +1,429 @@
import { arktypeResolver } from "@hookform/resolvers/arktype";
import { useQuery } from "@tanstack/react-query";
import { type } from "arktype";
import { useCallback, useState } from "react";
import { useForm } from "react-hook-form";
import { listRepositoriesOptions } from "~/api-client/@tanstack/react-query.gen";
import { RepositoryIcon } from "~/components/repository-icon";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "~/components/ui/form";
import { Input } from "~/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select";
import { VolumeFileBrowser } from "~/components/volume-file-browser";
import type { BackupSchedule, Volume } from "~/lib/types";
import { deepClean } from "~/utils/object";
const formSchema = type({
repositoryId: "string",
excludePatterns: "string[]?",
includePatterns: "string[]?",
frequency: "string",
dailyTime: "string?",
weeklyDay: "string?",
keepLast: "number?",
keepHourly: "number?",
keepDaily: "number?",
keepWeekly: "number?",
keepMonthly: "number?",
keepYearly: "number?",
});
const cleanSchema = type.pipe((d) => formSchema(deepClean(d)));
export const weeklyDays = [
{ label: "Monday", value: "1" },
{ label: "Tuesday", value: "2" },
{ label: "Wednesday", value: "3" },
{ label: "Thursday", value: "4" },
{ label: "Friday", value: "5" },
{ label: "Saturday", value: "6" },
{ label: "Sunday", value: "0" },
];
export type BackupScheduleFormValues = typeof formSchema.infer;
type Props = {
volume: Volume;
initialValues?: BackupSchedule;
onSubmit: (data: BackupScheduleFormValues) => void;
loading?: boolean;
summaryContent?: React.ReactNode;
formId: string;
};
const backupScheduleToFormValues = (schedule?: BackupSchedule): BackupScheduleFormValues | undefined => {
if (!schedule) {
return undefined;
}
const parts = schedule.cronExpression.split(" ");
const [minutePart, hourPart, , , dayOfWeekPart] = parts;
const isHourly = hourPart === "*";
const isDaily = !isHourly && dayOfWeekPart === "*";
const frequency = isHourly ? "hourly" : isDaily ? "daily" : "weekly";
const dailyTime = isHourly ? undefined : `${hourPart.padStart(2, "0")}:${minutePart.padStart(2, "0")}`;
const weeklyDay = frequency === "weekly" ? dayOfWeekPart : undefined;
return {
repositoryId: schedule.repositoryId,
frequency,
dailyTime,
weeklyDay,
includePatterns: schedule.includePatterns || undefined,
...schedule.retentionPolicy,
};
};
export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }: Props) => {
const form = useForm<BackupScheduleFormValues>({
resolver: arktypeResolver(cleanSchema as unknown as typeof formSchema),
defaultValues: backupScheduleToFormValues(initialValues),
});
const { data: repositoriesData } = useQuery({
...listRepositoriesOptions(),
});
const frequency = form.watch("frequency");
const formValues = form.watch();
const [selectedPaths, setSelectedPaths] = useState<Set<string>>(new Set(initialValues?.includePatterns || []));
const handleSelectionChange = useCallback(
(paths: Set<string>) => {
setSelectedPaths(paths);
form.setValue("includePatterns", Array.from(paths));
},
[form],
);
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="grid gap-4 xl:grid-cols-[minmax(0,_2.3fr)_minmax(320px,_1fr)]"
id={formId}
>
<div className="grid gap-4">
<Card>
<CardHeader>
<CardTitle>Backup automation</CardTitle>
<CardDescription className="mt-1">
Schedule automated backups of <strong>{volume.name}</strong> to a secure repository.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-6 md:grid-cols-2">
<FormField
control={form.control}
name="repositoryId"
render={({ field }) => (
<FormItem className="md:col-span-2">
<FormLabel>Backup repository</FormLabel>
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Select a repository" />
</SelectTrigger>
<SelectContent>
{repositoriesData?.map((repo) => (
<SelectItem key={repo.id} value={repo.id}>
<span className="flex items-center gap-2">
<RepositoryIcon backend={repo.type} />
{repo.name}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormDescription>
Choose where encrypted backups for <strong>{volume.name}</strong> will be stored.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="frequency"
render={({ field }) => (
<FormItem>
<FormLabel>Backup frequency</FormLabel>
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Select frequency" />
</SelectTrigger>
<SelectContent>
<SelectItem value="hourly">Hourly</SelectItem>
<SelectItem value="daily">Daily</SelectItem>
<SelectItem value="weekly">Weekly</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormDescription>Define how often snapshots should be taken.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{frequency !== "hourly" && (
<FormField
control={form.control}
name="dailyTime"
render={({ field }) => (
<FormItem>
<FormLabel>Execution time</FormLabel>
<FormControl>
<Input type="time" {...field} />
</FormControl>
<FormDescription>Time of day when the backup will run.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
{frequency === "weekly" && (
<FormField
control={form.control}
name="weeklyDay"
render={({ field }) => (
<FormItem className="md:col-span-2">
<FormLabel>Execution day</FormLabel>
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Select a day" />
</SelectTrigger>
<SelectContent>
{weeklyDays.map((day) => (
<SelectItem key={day.value} value={day.value}>
{day.label}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormDescription>Choose which day of the week to run the backup.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Backup paths</CardTitle>
<CardDescription>
Select which folders to include in the backup. If no paths are selected, the entire volume will be
backed up.
</CardDescription>
</CardHeader>
<CardContent>
<VolumeFileBrowser
volumeName={volume.name}
selectedPaths={selectedPaths}
onSelectionChange={handleSelectionChange}
withCheckboxes={true}
foldersOnly={true}
className="overflow-auto flex-1 border rounded-md bg-card p-2 min-h-[300px] max-h-[400px]"
/>
{selectedPaths.size > 0 && (
<div className="mt-4">
<p className="text-xs text-muted-foreground mb-2">Selected paths:</p>
<div className="flex flex-wrap gap-2">
{Array.from(selectedPaths).map((path) => (
<span key={path} className="text-xs bg-accent px-2 py-1 rounded-md font-mono">
{path}
</span>
))}
</div>
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Retention policy</CardTitle>
<CardDescription>Define how many snapshots to keep. Leave empty to keep all.</CardDescription>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2">
<FormField
control={form.control}
name="keepLast"
render={({ field }) => (
<FormItem>
<FormLabel>Keep last N snapshots</FormLabel>
<FormControl>
<Input
{...field}
type="number"
min={0}
placeholder="Optional"
onChange={(v) => field.onChange(Number(v.target.value))}
/>
</FormControl>
<FormDescription>Keep the N most recent snapshots.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="keepHourly"
render={({ field }) => (
<FormItem>
<FormLabel>Keep hourly</FormLabel>
<FormControl>
<Input
type="number"
min={0}
placeholder="Optional"
{...field}
onChange={(v) => field.onChange(Number(v.target.value))}
/>
</FormControl>
<FormDescription>Keep the last N hourly snapshots.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="keepDaily"
render={({ field }) => (
<FormItem>
<FormLabel>Keep daily</FormLabel>
<FormControl>
<Input
type="number"
min={0}
placeholder="e.g., 7"
{...field}
onChange={(v) => field.onChange(Number(v.target.value))}
/>
</FormControl>
<FormDescription>Keep the last N daily snapshots.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="keepWeekly"
render={({ field }) => (
<FormItem>
<FormLabel>Keep weekly</FormLabel>
<FormControl>
<Input
type="number"
min={0}
placeholder="e.g., 4"
{...field}
onChange={(v) => field.onChange(Number(v.target.value))}
/>
</FormControl>
<FormDescription>Keep the last N weekly snapshots.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="keepMonthly"
render={({ field }) => (
<FormItem>
<FormLabel>Keep monthly</FormLabel>
<FormControl>
<Input
type="number"
min={0}
placeholder="e.g., 6"
{...field}
onChange={(v) => field.onChange(Number(v.target.value))}
/>
</FormControl>
<FormDescription>Keep the last N monthly snapshots.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="keepYearly"
render={({ field }) => (
<FormItem>
<FormLabel>Keep yearly</FormLabel>
<FormControl>
<Input
type="number"
min={0}
placeholder="Optional"
{...field}
onChange={(v) => field.onChange(Number(v.target.value))}
/>
</FormControl>
<FormDescription>Keep the last N yearly snapshots.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
</div>
<div className="h-full">
<Card className="h-full">
<CardHeader className="flex flex-row items-center justify-between gap-4">
<div>
<CardTitle>Schedule summary</CardTitle>
<CardDescription>Review the backup configuration.</CardDescription>
</div>
</CardHeader>
<CardContent className="flex flex-col gap-4 text-sm">
<div>
<p className="text-xs uppercase text-muted-foreground">Volume</p>
<p className="font-medium">{volume.name}</p>
</div>
<div>
<p className="text-xs uppercase text-muted-foreground">Schedule</p>
<p className="font-medium">
{frequency ? frequency.charAt(0).toUpperCase() + frequency.slice(1) : "-"}
</p>
</div>
<div>
<p className="text-xs uppercase text-muted-foreground">Repository</p>
<p className="font-medium">
{repositoriesData?.find((r) => r.id === formValues.repositoryId)?.name || "—"}
</p>
</div>
<div>
<p className="text-xs uppercase text-muted-foreground">Retention</p>
<p className="font-medium">
{Object.entries(formValues)
.filter(([key, value]) => key.startsWith("keep") && Boolean(value))
.map(([key, value]) => {
const label = key.replace("keep", "").toLowerCase();
return `${value} ${label}`;
})
.join(", ") || "-"}
</p>
</div>
</CardContent>
</Card>
</div>
</form>
</Form>
);
};

View File

@@ -0,0 +1,152 @@
import { Pencil, Play, Trash2 } from "lucide-react";
import { useMemo, useState } from "react";
import { OnOff } from "~/components/onoff";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogHeader,
AlertDialogTitle,
} from "~/components/ui/alert-dialog";
import type { BackupSchedule } from "~/lib/types";
type Props = {
schedule: BackupSchedule;
handleToggleEnabled: (enabled: boolean) => void;
handleRunBackupNow: () => void;
handleDeleteSchedule: () => void;
setIsEditMode: (isEdit: boolean) => void;
};
export const ScheduleSummary = (props: Props) => {
const { schedule, handleToggleEnabled, handleRunBackupNow, handleDeleteSchedule, setIsEditMode } = props;
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const summary = useMemo(() => {
const scheduleLabel = schedule ? schedule.cronExpression : "-";
const retentionParts: string[] = [];
if (schedule?.retentionPolicy) {
const rp = schedule.retentionPolicy;
if (rp.keepLast) retentionParts.push(`${rp.keepLast} last`);
if (rp.keepHourly) retentionParts.push(`${rp.keepHourly} hourly`);
if (rp.keepDaily) retentionParts.push(`${rp.keepDaily} daily`);
if (rp.keepWeekly) retentionParts.push(`${rp.keepWeekly} weekly`);
if (rp.keepMonthly) retentionParts.push(`${rp.keepMonthly} monthly`);
if (rp.keepYearly) retentionParts.push(`${rp.keepYearly} yearly`);
}
return {
vol: schedule.volume.name,
scheduleLabel,
repositoryLabel: schedule.repositoryId || "No repository selected",
retentionLabel: retentionParts.length > 0 ? retentionParts.join(" • ") : "No retention policy",
};
}, [schedule, schedule.volume.name]);
const handleConfirmDelete = () => {
setShowDeleteConfirm(false);
handleDeleteSchedule();
};
return (
<div className="space-y-4">
<Card>
<CardHeader className="space-y-4">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<CardTitle>Backup schedule</CardTitle>
<CardDescription>
Automated backup configuration for volume&nbsp;
<strong className="text-strong-accent">{schedule.volume.name}</strong>
</CardDescription>
</div>
<div className="flex items-center gap-2 justify-between sm:justify-start">
<OnOff
isOn={schedule.enabled}
toggle={handleToggleEnabled}
enabledLabel="Enabled"
disabledLabel="Paused"
/>
</div>
</div>
<div className="flex flex-col sm:flex-row gap-2">
<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>
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setShowDeleteConfirm(true)}
className="text-destructive hover:text-destructive w-full sm:w-auto"
>
<Trash2 className="h-4 w-4 mr-2" />
<span className="sm:inline">Delete</span>
</Button>
</div>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<div>
<p className="text-xs uppercase text-muted-foreground">Schedule</p>
<p className="font-medium">{summary.scheduleLabel}</p>
</div>
<div>
<p className="text-xs uppercase text-muted-foreground">Repository</p>
<p className="font-medium">{schedule.repository.name}</p>
</div>
<div>
<p className="text-xs uppercase text-muted-foreground">Last backup</p>
<p className="font-medium">
{schedule.lastBackupAt ? new Date(schedule.lastBackupAt).toLocaleString() : "Never"}
</p>
</div>
<div>
<p className="text-xs uppercase text-muted-foreground">Next backup</p>
<p className="font-medium">
{schedule.nextBackupAt ? new Date(schedule.nextBackupAt).toLocaleString() : "Never"}
</p>
</div>
<div>
<p className="text-xs uppercase text-muted-foreground">Status</p>
<p className="font-medium">
{schedule.lastBackupStatus === "success" && "✓ Success"}
{schedule.lastBackupStatus === "error" && "✗ Error"}
{!schedule.lastBackupStatus && "—"}
</p>
</div>
</CardContent>
</Card>
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete backup schedule?</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete this backup schedule for <strong>{schedule.volume.name}</strong>? This
action cannot be undone. Existing snapshots will not be deleted.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="flex gap-3 justify-end">
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete schedule
</AlertDialogAction>
</div>
</AlertDialogContent>
</AlertDialog>
</div>
);
};

View File

@@ -0,0 +1,267 @@
import { useCallback, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { FileIcon } from "lucide-react";
import { listSnapshotFilesOptions, restoreSnapshotMutation } from "~/api-client/@tanstack/react-query.gen";
import { FileTree, type FileEntry } from "~/components/file-tree";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
import { Button } from "~/components/ui/button";
import { Checkbox } from "~/components/ui/checkbox";
import { Label } from "~/components/ui/label";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "~/components/ui/alert-dialog";
import type { Snapshot } from "~/lib/types";
import { toast } from "sonner";
interface Props {
snapshot: Snapshot;
repositoryName: string;
}
export const SnapshotFileBrowser = (props: Props) => {
const { snapshot, repositoryName } = 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 [selectedPaths, setSelectedPaths] = useState<Set<string>>(new Set());
const [showRestoreDialog, setShowRestoreDialog] = useState(false);
const [deleteExtraFiles, setDeleteExtraFiles] = useState(false);
const volumeBasePath = snapshot.paths[0]?.match(/^(.*?_data)(\/|$)/)?.[1] || "";
const { data: filesData, isLoading: filesLoading } = useQuery({
...listSnapshotFilesOptions({
path: { name: repositoryName, snapshotId: snapshot.short_id },
query: { path: volumeBasePath },
}),
});
const stripBasePath = useCallback(
(path: string): string => {
if (!volumeBasePath) return path;
if (path === volumeBasePath) return "/";
if (path.startsWith(`${volumeBasePath}/`)) {
const stripped = path.slice(volumeBasePath.length);
return stripped;
}
return path;
},
[volumeBasePath],
);
const addBasePath = useCallback(
(displayPath: string): string => {
if (!volumeBasePath) return displayPath;
if (displayPath === "/") return volumeBasePath;
return `${volumeBasePath}${displayPath}`;
},
[volumeBasePath],
);
useMemo(() => {
if (filesData?.files) {
setAllFiles((prev) => {
const next = new Map(prev);
for (const file of filesData.files) {
const strippedPath = stripBasePath(file.path);
if (strippedPath !== "/") {
next.set(strippedPath, { ...file, path: strippedPath });
}
}
return next;
});
setFetchedFolders((prev) => new Set(prev).add("/"));
}
}, [filesData, stripBasePath]);
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 fullPath = addBasePath(folderPath);
const result = await queryClient.fetchQuery(
listSnapshotFilesOptions({
path: { name: repositoryName, snapshotId: snapshot.short_id },
query: { path: fullPath },
}),
);
if (result.files) {
setAllFiles((prev) => {
const next = new Map(prev);
for (const file of result.files) {
const strippedPath = stripBasePath(file.path);
// Skip the directory itself
if (strippedPath !== folderPath) {
next.set(strippedPath, { ...file, path: strippedPath });
}
}
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;
});
}
}
},
[repositoryName, snapshot, fetchedFolders, queryClient, stripBasePath, addBasePath],
);
const handleFolderHover = useCallback(
(folderPath: string) => {
if (!fetchedFolders.has(folderPath) && !loadingFolders.has(folderPath)) {
const fullPath = addBasePath(folderPath);
queryClient.prefetchQuery(
listSnapshotFilesOptions({
path: { name: repositoryName, snapshotId: snapshot.short_id },
query: { path: fullPath },
}),
);
}
},
[repositoryName, snapshot, fetchedFolders, loadingFolders, queryClient, addBasePath],
);
const { mutate: restoreSnapshot, isPending: isRestoring } = useMutation({
...restoreSnapshotMutation(),
onSuccess: (data) => {
toast.success("Restore completed", {
description: `Successfully restored ${data.filesRestored} file(s). ${data.filesSkipped} file(s) skipped.`,
});
setSelectedPaths(new Set());
},
onError: (error) => {
toast.error("Restore failed", { description: error.message || "Failed to restore snapshot" });
},
});
const handleRestoreClick = useCallback(() => {
setShowRestoreDialog(true);
}, []);
const handleConfirmRestore = useCallback(() => {
const pathsArray = Array.from(selectedPaths);
const includePaths = pathsArray.map((path) => addBasePath(path));
restoreSnapshot({
path: { name: repositoryName },
body: {
snapshotId: snapshot.short_id,
include: includePaths,
delete: deleteExtraFiles,
},
});
setShowRestoreDialog(false);
}, [selectedPaths, addBasePath, repositoryName, snapshot.short_id, restoreSnapshot, deleteExtraFiles]);
return (
<div className="space-y-4">
<Card className="h-[600px] flex flex-col">
<CardHeader>
<div className="flex items-start justify-between">
<div>
<CardTitle>File Browser</CardTitle>
<CardDescription>{`Viewing snapshot from ${new Date(snapshot?.time ?? 0).toLocaleString()}`}</CardDescription>
</div>
{selectedPaths.size > 0 && (
<Button onClick={handleRestoreClick} variant="primary" size="sm" disabled={isRestoring}>
{isRestoring
? "Restoring..."
: `Restore ${selectedPaths.size} selected ${selectedPaths.size === 1 ? "item" : "items"}`}
</Button>
)}
</div>
</CardHeader>
<CardContent className="flex-1 overflow-hidden flex flex-col p-0">
{filesLoading && fileArray.length === 0 && (
<div className="flex items-center justify-center flex-1">
<p className="text-muted-foreground">Loading files...</p>
</div>
)}
{fileArray.length === 0 && !filesLoading && (
<div className="flex flex-col items-center justify-center flex-1 text-center p-8">
<FileIcon className="w-12 h-12 text-muted-foreground/50 mb-4" />
<p className="text-muted-foreground">No files in this snapshot</p>
</div>
)}
{fileArray.length > 0 && (
<div className="overflow-auto flex-1 border border-border rounded-md bg-card m-4">
<FileTree
files={fileArray}
onFolderExpand={handleFolderExpand}
onFolderHover={handleFolderHover}
expandedFolders={expandedFolders}
loadingFolders={loadingFolders}
className="px-2 py-2"
withCheckboxes={true}
selectedPaths={selectedPaths}
onSelectionChange={setSelectedPaths}
/>
</div>
)}
</CardContent>
</Card>
<AlertDialog open={showRestoreDialog} onOpenChange={setShowRestoreDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Confirm Restore</AlertDialogTitle>
<AlertDialogDescription>
{selectedPaths.size > 0
? `This will restore ${selectedPaths.size} selected ${selectedPaths.size === 1 ? "item" : "items"} from the snapshot.`
: "This will restore everything from the snapshot."}{" "}
Existing files will be overwritten by what's in the snapshot. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="flex items-center space-x-2 py-4">
<Checkbox
id="delete-extra"
checked={deleteExtraFiles}
onCheckedChange={(checked) => setDeleteExtraFiles(checked === true)}
/>
<Label htmlFor="delete-extra" className="text-sm font-normal cursor-pointer">
Delete files not present in the snapshot?
</Label>
</div>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleConfirmRestore}>Confirm</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
};

View File

@@ -0,0 +1,79 @@
import type { ListSnapshotsResponse } from "~/api-client/types.gen";
import { cn } from "~/lib/utils";
import { Card } from "~/components/ui/card";
import { ByteSize } from "~/components/bytes-size";
interface Props {
snapshots: ListSnapshotsResponse;
snapshotId: string;
onSnapshotSelect: (snapshotId: string) => void;
}
export const SnapshotTimeline = (props: Props) => {
const { snapshots, snapshotId, onSnapshotSelect } = props;
if (snapshots.length === 0) {
return (
<div className="w-full bg-card border-t border-border py-4 px-4">
<div className="flex items-center justify-center h-24">
<p className="text-muted-foreground">No snapshots available</p>
</div>
</div>
);
}
return (
<Card className="p-0 pt-2">
<div className="w-full bg-card">
<div className="relative flex items-center">
<div className="flex-1 overflow-hidden">
<div className="flex gap-4 overflow-x-auto pb-2 [&>:first-child]:ml-2 [&>:last-child]:mr-2">
{snapshots.map((snapshot, index) => {
const date = new Date(snapshot.time);
const isSelected = snapshotId === snapshot.short_id;
const isLatest = index === snapshots.length - 1;
return (
<button
type="button"
key={snapshot.short_id}
onClick={() => onSnapshotSelect(snapshot.short_id)}
className={cn(
"shrink-0 flex flex-col items-center gap-2 p-3 rounded-lg transition-all",
"border-2 cursor-pointer",
{
"border-primary bg-primary/10 shadow-md": isSelected,
"border-border hover:border-accent hover:bg-accent/5": !isSelected,
},
)}
>
<div className="text-xs font-semibold text-foreground">
{date.toLocaleDateString("en-US", { month: "short", day: "numeric" })}
</div>
<div className="text-xs text-muted-foreground">
{date.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" })}
</div>
<div className="text-xs text-muted-foreground opacity-75">
<ByteSize bytes={snapshot.size} />
</div>
{isLatest && (
<div className="text-xs font-semibold text-primary px-2 py-0.5 bg-primary/20 rounded">Latest</div>
)}
</button>
);
})}
</div>
</div>
</div>
<div className="px-4 py-2 text-xs text-muted-foreground bg-card-header border-t border-border flex justify-between">
<span>{snapshots.length} snapshots</span>
<span>
{new Date(snapshots[0].time).toLocaleDateString()} -{" "}
{new Date(snapshots.at(-1)?.time ?? 0).toLocaleDateString()}
</span>
</div>
</div>
</Card>
);
};

View File

@@ -0,0 +1,196 @@
import { useId, useState } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { redirect, useNavigate } from "react-router";
import { toast } from "sonner";
import { Button } from "~/components/ui/button";
import {
getBackupScheduleOptions,
runBackupNowMutation,
deleteBackupScheduleMutation,
listSnapshotsOptions,
updateBackupScheduleMutation,
} from "~/api-client/@tanstack/react-query.gen";
import { parseError } from "~/lib/errors";
import { getCronExpression } from "~/utils/utils";
import { CreateScheduleForm, type BackupScheduleFormValues } from "../components/create-schedule-form";
import { ScheduleSummary } from "../components/schedule-summary";
import { getBackupSchedule, listSnapshots } from "~/api-client";
import type { Route } from "./+types/backup-details";
import { SnapshotFileBrowser } from "../components/snapshot-file-browser";
import { SnapshotTimeline } from "../components/snapshot-timeline";
export const clientLoader = async ({ params }: Route.LoaderArgs) => {
const { data } = await getBackupSchedule({ path: { scheduleId: params.id } });
if (!data) return redirect("/backups");
const snapshots = await listSnapshots({
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) {
const navigate = useNavigate();
const [isEditMode, setIsEditMode] = useState(false);
const formId = useId();
const [selectedSnapshotId, setSelectedSnapshotId] = useState<string>(loaderData.snapshots.at(-1)?.short_id ?? "");
const { data: schedule } = useQuery({
...getBackupScheduleOptions({
path: { scheduleId: params.id },
}),
initialData: loaderData.schedule,
});
const { data: snapshots } = useQuery({
...listSnapshotsOptions({
path: { name: schedule.repository.name },
query: { backupId: schedule.id.toString() },
}),
initialData: loaderData.snapshots,
});
const upsertSchedule = useMutation({
...updateBackupScheduleMutation(),
onSuccess: () => {
toast.success("Backup schedule saved successfully");
setIsEditMode(false);
},
onError: (error) => {
toast.error("Failed to save backup schedule", {
description: parseError(error)?.message,
});
},
});
const runBackupNow = useMutation({
...runBackupNowMutation(),
onSuccess: () => {
toast.success("Backup started successfully");
},
onError: (error) => {
toast.error("Failed to start backup", {
description: parseError(error)?.message,
});
},
});
const deleteSchedule = useMutation({
...deleteBackupScheduleMutation(),
onSuccess: () => {
toast.success("Backup schedule deleted successfully");
navigate("/backups");
},
onError: (error) => {
toast.error("Failed to delete backup schedule", {
description: parseError(error)?.message,
});
},
});
const handleSubmit = (formValues: BackupScheduleFormValues) => {
if (!schedule) return;
const cronExpression = getCronExpression(formValues.frequency, formValues.dailyTime, formValues.weeklyDay);
const retentionPolicy: Record<string, number> = {};
if (formValues.keepLast) retentionPolicy.keepLast = formValues.keepLast;
if (formValues.keepHourly) retentionPolicy.keepHourly = formValues.keepHourly;
if (formValues.keepDaily) retentionPolicy.keepDaily = formValues.keepDaily;
if (formValues.keepWeekly) retentionPolicy.keepWeekly = formValues.keepWeekly;
if (formValues.keepMonthly) retentionPolicy.keepMonthly = formValues.keepMonthly;
if (formValues.keepYearly) retentionPolicy.keepYearly = formValues.keepYearly;
upsertSchedule.mutate({
path: { scheduleId: schedule.id.toString() },
body: {
repositoryId: formValues.repositoryId,
enabled: schedule.enabled,
cronExpression,
retentionPolicy: Object.keys(retentionPolicy).length > 0 ? retentionPolicy : undefined,
includePatterns: formValues.includePatterns,
excludePatterns: formValues.excludePatterns,
},
});
};
const handleToggleEnabled = (enabled: boolean) => {
if (!schedule) return;
upsertSchedule.mutate({
path: { scheduleId: schedule.id.toString() },
body: {
repositoryId: schedule.repositoryId,
enabled,
cronExpression: schedule.cronExpression,
retentionPolicy: schedule.retentionPolicy || undefined,
includePatterns: schedule.includePatterns || undefined,
excludePatterns: schedule.excludePatterns || undefined,
},
});
};
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}>
Update schedule
</Button>
<Button variant="outline" onClick={() => setIsEditMode(false)}>
Cancel
</Button>
</div>
</div>
);
}
const selectedSnapshot = snapshots.find((s) => s.short_id === selectedSnapshotId);
return (
<div className="flex flex-col gap-6">
<ScheduleSummary
handleToggleEnabled={handleToggleEnabled}
handleRunBackupNow={handleRunBackupNow}
handleDeleteSchedule={handleDeleteSchedule}
setIsEditMode={setIsEditMode}
schedule={schedule}
/>
{selectedSnapshot && (
<>
<SnapshotTimeline
snapshots={snapshots}
snapshotId={selectedSnapshot.short_id}
onSnapshotSelect={setSelectedSnapshotId}
/>
<SnapshotFileBrowser
key={selectedSnapshot.short_id}
snapshot={selectedSnapshot}
repositoryName={schedule.repository.name}
/>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,123 @@
import { useQuery } from "@tanstack/react-query";
import { CalendarClock, Database, HardDrive, Plus } from "lucide-react";
import { Link } from "react-router";
import { listBackupSchedules } from "~/api-client";
import { listBackupSchedulesOptions } from "~/api-client/@tanstack/react-query.gen";
import { BackupStatusDot } from "../components/backup-status-dot";
import { EmptyState } from "~/components/empty-state";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
import type { Route } from "./+types/backups";
export function meta(_: Route.MetaArgs) {
return [
{ title: "Ironmount" },
{
name: "description",
content: "Create, manage, monitor, and automate your Docker volumes with ease.",
},
];
}
export const clientLoader = async () => {
const jobs = await listBackupSchedules();
if (jobs.data) return jobs.data;
return [];
};
export default function Backups({ loaderData }: Route.ComponentProps) {
const { data: schedules, isLoading } = useQuery({
...listBackupSchedulesOptions(),
initialData: loaderData,
refetchInterval: 10000,
refetchOnWindowFocus: true,
});
if (isLoading) {
return (
<div className="flex items-center justify-center h-full">
<p className="text-muted-foreground">Loading backup schedules...</p>
</div>
);
}
if (!schedules || schedules.length === 0) {
return (
<EmptyState
icon={CalendarClock}
title="No backup job"
description="Backup jobs help you automate the process of backing up your volumes on a regular schedule to ensure your data is safe and secure."
button={
<Button>
<Link to="/backups/create" className="flex items-center">
<Plus className="h-4 w-4 mr-2" />
Create a backup job
</Link>
</Button>
}
/>
);
}
return (
<div className="container mx-auto space-y-6">
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3 auto-rows-fr">
{schedules.map((schedule) => (
<Link key={schedule.id} to={`/backups/${schedule.id}`}>
<Card key={schedule.id} className="flex flex-col h-full">
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2 flex-1 min-w-0">
<HardDrive className="h-5 w-5 text-muted-foreground flex-shrink-0" />
<CardTitle className="text-lg truncate">
Volume <span className="text-strong-accent">{schedule.volume.name}</span>
</CardTitle>
</div>
<BackupStatusDot enabled={schedule.enabled} hasError={!!schedule.lastBackupError} />
</div>
<CardDescription className="flex items-center gap-2 mt-2">
<Database className="h-4 w-4" />
<span className="truncate">{schedule.repository.name}</span>
</CardDescription>
</CardHeader>
<CardContent className="flex-1 space-y-4">
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Schedule</span>
<code className="text-xs bg-muted px-2 py-1 rounded">{schedule.cronExpression}</code>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Last backup</span>
<span className="font-medium">
{schedule.lastBackupAt ? new Date(schedule.lastBackupAt).toLocaleDateString() : "Never"}
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Next backup</span>
<span className="font-medium">
{schedule.nextBackupAt ? new Date(schedule.nextBackupAt).toLocaleDateString() : "N/A"}
</span>
</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>
</CardContent>
</Card>
</Link>
))}
<Link to="/backups/create">
<Card className="flex flex-col items-center justify-center h-full hover:bg-muted/50 transition-colors cursor-pointer">
<CardContent className="flex flex-col items-center justify-center gap-2">
<Plus className="h-8 w-8 text-muted-foreground" />
<span className="text-sm font-medium text-muted-foreground">Create a backup job</span>
</CardContent>
</Card>
</Link>
</div>
</div>
);
}

View File

@@ -0,0 +1,185 @@
import { useId, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Database, HardDrive } from "lucide-react";
import { Link, useNavigate } from "react-router";
import { toast } from "sonner";
import {
createBackupScheduleMutation,
listRepositoriesOptions,
listVolumesOptions,
} from "~/api-client/@tanstack/react-query.gen";
import { Button } from "~/components/ui/button";
import { Card, CardContent } from "~/components/ui/card";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select";
import { parseError } from "~/lib/errors";
import { EmptyState } from "~/components/empty-state";
import { getCronExpression } from "~/utils/utils";
import { CreateScheduleForm, type BackupScheduleFormValues } from "../components/create-schedule-form";
import type { Route } from "./+types/create-backup";
import { listRepositories, listVolumes } from "~/api-client";
export function meta(_: Route.MetaArgs) {
return [
{ title: "Ironmount" },
{
name: "description",
content: "Create, manage, monitor, and automate your Docker volumes with ease.",
},
];
}
export const clientLoader = async () => {
const volumes = await listVolumes();
const repositories = await listRepositories();
if (volumes.data && repositories.data) return { volumes: volumes.data, repositories: repositories.data };
return { volumes: [], repositories: [] };
};
export default function CreateBackup({ loaderData }: Route.ComponentProps) {
const navigate = useNavigate();
const formId = useId();
const [selectedVolumeId, setSelectedVolumeId] = useState<number | undefined>();
const { data: volumesData, isLoading: loadingVolumes } = useQuery({
...listVolumesOptions(),
initialData: loaderData.volumes,
});
const { data: repositoriesData } = useQuery({
...listRepositoriesOptions(),
initialData: loaderData.repositories,
});
const createSchedule = useMutation({
...createBackupScheduleMutation(),
onSuccess: (data) => {
toast.success("Backup job created successfully");
navigate(`/backups/${data.id}`);
},
onError: (error) => {
toast.error("Failed to create backup job", {
description: parseError(error)?.message,
});
},
});
const handleSubmit = (formValues: BackupScheduleFormValues) => {
if (!selectedVolumeId) return;
const cronExpression = getCronExpression(formValues.frequency, formValues.dailyTime, formValues.weeklyDay);
const retentionPolicy: Record<string, number> = {};
if (formValues.keepLast) retentionPolicy.keepLast = formValues.keepLast;
if (formValues.keepHourly) retentionPolicy.keepHourly = formValues.keepHourly;
if (formValues.keepDaily) retentionPolicy.keepDaily = formValues.keepDaily;
if (formValues.keepWeekly) retentionPolicy.keepWeekly = formValues.keepWeekly;
if (formValues.keepMonthly) retentionPolicy.keepMonthly = formValues.keepMonthly;
if (formValues.keepYearly) retentionPolicy.keepYearly = formValues.keepYearly;
createSchedule.mutate({
body: {
volumeId: selectedVolumeId,
repositoryId: formValues.repositoryId,
enabled: true,
cronExpression,
retentionPolicy: Object.keys(retentionPolicy).length > 0 ? retentionPolicy : undefined,
includePatterns: formValues.includePatterns,
excludePatterns: formValues.excludePatterns,
},
});
};
const selectedVolume = volumesData.find((v) => v.id === selectedVolumeId);
if (loadingVolumes) {
return (
<div className="flex items-center justify-center h-full">
<p className="text-muted-foreground">Loading...</p>
</div>
);
}
if (!volumesData.length) {
return (
<EmptyState
icon={HardDrive}
title="No volume to backup"
description="To create a backup job, you need to create a volume first. Volumes are the data sources that will be backed up."
button={
<Button>
<Link to="/volumes">Go to volumes</Link>
</Button>
}
/>
);
}
if (!repositoriesData?.length) {
return (
<EmptyState
icon={Database}
title="No repository"
description="To create a backup job, you need to set up a backup repository first. Backup repositories are the destinations where your backups will be stored."
button={
<Button>
<Link to="/repositories">Go to repositories</Link>
</Button>
}
/>
);
}
return (
<div className="container mx-auto space-y-6">
<Card>
<CardContent>
<Select value={selectedVolumeId?.toString()} onValueChange={(v) => setSelectedVolumeId(Number(v))}>
<SelectTrigger id="volume-select">
<SelectValue placeholder="Choose a volume to backup" />
</SelectTrigger>
<SelectContent>
{volumesData.map((volume) => (
<SelectItem key={volume.id} value={volume.id.toString()}>
<span className="flex items-center gap-2">
<HardDrive className="h-4 w-4" />
{volume.name}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</CardContent>
</Card>
{selectedVolume ? (
<>
<CreateScheduleForm volume={selectedVolume} onSubmit={handleSubmit} formId={formId} />
<div className="flex justify-end mt-4 gap-2">
<Button type="submit" variant="primary" form={formId} loading={createSchedule.isPending}>
Create
</Button>
</div>
</>
) : (
<Card>
<CardContent className="py-16">
<div className="flex flex-col items-center justify-center text-center">
<div className="relative mb-6">
<div className="absolute inset-0 animate-pulse">
<div className="w-24 h-24 rounded-full bg-primary/10 blur-2xl" />
</div>
<div className="relative flex items-center justify-center w-24 h-24 rounded-full bg-gradient-to-br from-primary/20 to-primary/5 border-2 border-primary/20">
<Database className="w-12 h-12 text-primary/70" strokeWidth={1.5} />
</div>
</div>
<h3 className="text-xl font-semibold mb-2">Select a volume</h3>
<p className="text-muted-foreground text-sm max-w-md">
Choose a volume from the dropdown above to configure its backup schedule.
</p>
</div>
</CardContent>
</Card>
)}
</div>
);
}

View File

@@ -1,607 +0,0 @@
import { useMemo } from "react";
import { useForm } from "react-hook-form";
import { OnOff } from "~/components/onoff";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "~/components/ui/card";
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "~/components/ui/form";
import { Input } from "~/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select";
import { Switch } from "~/components/ui/switch";
import type { Volume } from "~/lib/types";
type BackupDestination = "s3" | "sftp" | "filesystem";
type BackupFrequency = "hourly" | "daily" | "weekly";
type BackupEncryption = "none" | "aes256" | "gpg";
type BackupFormValues = {
isEnabled: boolean;
destination: BackupDestination;
frequency: BackupFrequency;
dailyTime: string;
weeklyDay: string;
retentionCopies: string;
retentionDays: string;
notifyOnFailure: boolean;
notificationWebhook: string;
encryption: BackupEncryption;
encryptionPassword: string;
s3Bucket: string;
s3Region: string;
s3PathPrefix: string;
sftpHost: string;
sftpPort: string;
sftpUsername: string;
sftpPath: string;
filesystemPath: string;
};
type Props = {
volume: Volume;
};
const weeklyDays = [
{ label: "Monday", value: "monday" },
{ label: "Tuesday", value: "tuesday" },
{ label: "Wednesday", value: "wednesday" },
{ label: "Thursday", value: "thursday" },
{ label: "Friday", value: "friday" },
{ label: "Saturday", value: "saturday" },
{ label: "Sunday", value: "sunday" },
];
export const VolumeBackupsTabContent = ({ volume }: Props) => {
const form = useForm<BackupFormValues>({
defaultValues: {
isEnabled: true,
destination: "s3",
frequency: "daily",
dailyTime: "02:00",
weeklyDay: "sunday",
retentionCopies: "7",
retentionDays: "30",
notifyOnFailure: true,
notificationWebhook: "",
encryption: "aes256",
encryptionPassword: "",
s3Bucket: "",
s3Region: "us-east-1",
s3PathPrefix: `${volume.name}/backups`,
sftpHost: "",
sftpPort: "22",
sftpUsername: "",
sftpPath: `/backups/${volume.name}`,
filesystemPath: `/var/backups/${volume.name}`,
},
});
const destination = form.watch("destination");
const frequency = form.watch("frequency");
const encryption = form.watch("encryption");
const notifyOnFailure = form.watch("notifyOnFailure");
const values = form.watch();
const summary = useMemo(() => {
const scheduleLabel =
frequency === "hourly"
? "Every hour"
: frequency === "daily"
? `Every day at ${values.dailyTime}`
: `Every ${values.weeklyDay.charAt(0).toUpperCase()}${values.weeklyDay.slice(1)} at ${values.dailyTime}`;
const destinationLabel = (() => {
if (destination === "s3") {
return `Amazon S3 → ${values.s3Bucket || "<bucket>"} (${values.s3Region})`;
}
if (destination === "sftp") {
return `SFTP → ${values.sftpUsername || "user"}@${values.sftpHost || "server"}:${values.sftpPath}`;
}
return `Filesystem → ${values.filesystemPath}`;
})();
return {
vol: volume.name,
scheduleLabel,
destinationLabel,
encryptionLabel: encryption === "none" ? "Disabled" : encryption.toUpperCase(),
retentionLabel: `${values.retentionCopies} copies \u2022 ${values.retentionDays} days`,
notificationsLabel: notifyOnFailure
? values.notificationWebhook
? `Webhook to ${values.notificationWebhook}`
: "Webhook pending configuration"
: "Disabled",
};
}, [
destination,
encryption,
frequency,
notifyOnFailure,
values.dailyTime,
values.filesystemPath,
values.notificationWebhook,
values.retentionCopies,
values.retentionDays,
values.s3Bucket,
values.s3Region,
values.sftpHost,
values.sftpPath,
values.sftpUsername,
values.weeklyDay,
volume.name,
]);
const handleSubmit = (formValues: BackupFormValues) => {
console.info("Backup configuration", formValues);
};
return (
<div className="relative">
<div className="grid gap-4 xl:grid-cols-[minmax(0,_2.3fr)_minmax(320px,_1fr)]">
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="grid gap-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between gap-4">
<div>
<CardTitle>Backup automation</CardTitle>
<CardDescription className="mt-1">
Enable scheduled snapshots and off-site replication for this volume.
</CardDescription>
</div>
<FormField
control={form.control}
name="isEnabled"
render={({ field }) => (
<FormItem className="flex flex-col items-center space-y-2">
<FormControl>
<OnOff
isOn={field.value}
toggle={field.onChange}
enabledLabel="Enabled"
disabledLabel="Paused"
/>
</FormControl>
</FormItem>
)}
/>
</CardHeader>
<CardContent className="grid gap-6 md:grid-cols-2">
<FormField
control={form.control}
name="destination"
render={({ field }) => (
<FormItem>
<FormLabel>Destination provider</FormLabel>
<FormControl>
<Select onValueChange={field.onChange} value={field.value}>
<SelectTrigger>
<SelectValue placeholder="Select a destination" />
</SelectTrigger>
<SelectContent>
<SelectItem value="s3">Amazon S3</SelectItem>
<SelectItem value="sftp">SFTP server</SelectItem>
<SelectItem value="filesystem">Local filesystem</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormDescription>
Choose where backups for <strong>{volume.name}</strong> will be stored.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="frequency"
render={({ field }) => (
<FormItem>
<FormLabel>Backup frequency</FormLabel>
<FormControl>
<Select onValueChange={field.onChange} value={field.value}>
<SelectTrigger>
<SelectValue placeholder="Select frequency" />
</SelectTrigger>
<SelectContent>
<SelectItem value="hourly">Hourly</SelectItem>
<SelectItem value="daily">Daily</SelectItem>
<SelectItem value="weekly">Weekly</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormDescription>Define how often snapshots should be taken.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{frequency !== "hourly" && (
<FormField
control={form.control}
name="dailyTime"
render={({ field }) => (
<FormItem>
<FormLabel>Execution time</FormLabel>
<FormControl>
<Input type="time" value={field.value} onChange={field.onChange} />
</FormControl>
<FormDescription>Time of day when the backup will run.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
{frequency === "weekly" && (
<FormField
control={form.control}
name="weeklyDay"
render={({ field }) => (
<FormItem>
<FormLabel>Execution day</FormLabel>
<FormControl>
<Select onValueChange={field.onChange} value={field.value}>
<SelectTrigger>
<SelectValue placeholder="Select a day" />
</SelectTrigger>
<SelectContent>
{weeklyDays.map((day) => (
<SelectItem key={day.value} value={day.value}>
{day.label}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormDescription>Choose which day of the week to run the backup.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="retentionCopies"
render={({ field }) => (
<FormItem>
<FormLabel>Max copies to retain</FormLabel>
<FormControl>
<Input
type="number"
min={1}
value={field.value}
onChange={(event) => field.onChange(event.target.value)}
/>
</FormControl>
<FormDescription>Oldest backups will be pruned after this many copies.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="retentionDays"
render={({ field }) => (
<FormItem>
<FormLabel>Retention window (days)</FormLabel>
<FormControl>
<Input
type="number"
min={1}
value={field.value}
onChange={(event) => field.onChange(event.target.value)}
/>
</FormControl>
<FormDescription>Backups older than this window will be removed.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
{destination === "s3" && (
<Card>
<CardHeader>
<CardTitle>Amazon S3 bucket</CardTitle>
<CardDescription>
Define the bucket and path where compressed archives will be uploaded.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2">
<FormField
control={form.control}
name="s3Bucket"
render={({ field }) => (
<FormItem>
<FormLabel>Bucket name</FormLabel>
<FormControl>
<Input placeholder="ironmount-backups" value={field.value} onChange={field.onChange} />
</FormControl>
<FormDescription>
Ensure the bucket has versioning and lifecycle rules as needed.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="s3Region"
render={({ field }) => (
<FormItem>
<FormLabel>Region</FormLabel>
<FormControl>
<Input placeholder="us-east-1" value={field.value} onChange={field.onChange} />
</FormControl>
<FormDescription>AWS region where the bucket resides.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="s3PathPrefix"
render={({ field }) => (
<FormItem className="md:col-span-2">
<FormLabel>Object prefix</FormLabel>
<FormControl>
<Input placeholder="volume-name/backups" value={field.value} onChange={field.onChange} />
</FormControl>
<FormDescription>
Backups will be stored under this key prefix inside the bucket.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
)}
{destination === "sftp" && (
<Card>
<CardHeader>
<CardTitle>SFTP target</CardTitle>
<CardDescription>
Connect to a remote host that will receive encrypted backup archives.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2">
<FormField
control={form.control}
name="sftpHost"
render={({ field }) => (
<FormItem>
<FormLabel>Hostname</FormLabel>
<FormControl>
<Input placeholder="backup.example.com" value={field.value} onChange={field.onChange} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="sftpPort"
render={({ field }) => (
<FormItem>
<FormLabel>Port</FormLabel>
<FormControl>
<Input type="number" min={1} value={field.value} onChange={field.onChange} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="sftpUsername"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder="backup" value={field.value} onChange={field.onChange} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="sftpPath"
render={({ field }) => (
<FormItem>
<FormLabel>Destination path</FormLabel>
<FormControl>
<Input placeholder="/var/backups/ironmount" value={field.value} onChange={field.onChange} />
</FormControl>
<FormDescription>Ensure the directory exists and has write permissions.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
)}
{destination === "filesystem" && (
<Card>
<CardHeader>
<CardTitle>Filesystem target</CardTitle>
<CardDescription>Persist archives to a directory on the host running Ironmount.</CardDescription>
</CardHeader>
<CardContent className="grid gap-4">
<FormField
control={form.control}
name="filesystemPath"
render={({ field }) => (
<FormItem>
<FormLabel>Backup directory</FormLabel>
<FormControl>
<Input placeholder="/var/backups/volume-name" value={field.value} onChange={field.onChange} />
</FormControl>
<FormDescription>The directory must be mounted with sufficient capacity.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle>Encryption & notifications</CardTitle>
<CardDescription>Secure backups and stay informed when something goes wrong.</CardDescription>
</CardHeader>
<CardContent className="grid gap-6 md:grid-cols-2">
<FormField
control={form.control}
name="encryption"
render={({ field }) => (
<FormItem>
<FormLabel>Encryption</FormLabel>
<FormControl>
<Select onValueChange={field.onChange} value={field.value}>
<SelectTrigger>
<SelectValue placeholder="Select encryption" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">Disabled</SelectItem>
<SelectItem value="aes256">AES-256 (managed key)</SelectItem>
<SelectItem value="gpg">GPG (bring your own)</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormDescription>Protect backups at rest with optional encryption.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{encryption !== "none" && (
<FormField
control={form.control}
name="encryptionPassword"
render={({ field }) => (
<FormItem>
<FormLabel>Encryption secret</FormLabel>
<FormControl>
<Input type="password" placeholder="••••••••" value={field.value} onChange={field.onChange} />
</FormControl>
<FormDescription>
Store this password securely. It will be required to restore backups.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="notifyOnFailure"
render={({ field }) => (
<FormItem className="flex flex-col space-y-2">
<FormLabel>Failure alerts</FormLabel>
<div className="flex items-center justify-between rounded-lg border px-3 py-2">
<div className="space-y-1">
<p className="text-sm font-medium">Webhook notifications</p>
<p className="text-xs text-muted-foreground">Send an HTTP POST when a backup fails.</p>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</div>
<FormMessage />
</FormItem>
)}
/>
{notifyOnFailure && (
<FormField
control={form.control}
name="notificationWebhook"
render={({ field }) => (
<FormItem className="md:col-span-2">
<FormLabel>Webhook URL</FormLabel>
<FormControl>
<Input
placeholder="https://hooks.example.com/ironmount"
value={field.value}
onChange={field.onChange}
/>
</FormControl>
<FormDescription>Ironmount will POST a JSON payload with failure details.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
</CardContent>
<CardFooter className="border-t pt-6">
<Button type="submit" className="ml-auto" variant="default">
Save draft configuration
</Button>
</CardFooter>
</Card>
</form>
</Form>
<Card className="h-full">
<CardHeader>
<CardTitle>Runbook summary</CardTitle>
<CardDescription>Validate the automation before enabling it in production.</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4 text-sm">
<div>
<p className="text-xs uppercase text-muted-foreground">Volume</p>
<p className="font-medium">{summary.vol}</p>
</div>
<div>
<p className="text-xs uppercase text-muted-foreground">Schedule</p>
<p className="font-medium">{summary.scheduleLabel}</p>
</div>
<div>
<p className="text-xs uppercase text-muted-foreground">Destination</p>
<p className="font-medium">{summary.destinationLabel}</p>
</div>
<div>
<p className="text-xs uppercase text-muted-foreground">Retention</p>
<p className="font-medium">{summary.retentionLabel}</p>
</div>
<div>
<p className="text-xs uppercase text-muted-foreground">Encryption</p>
<p className="font-medium">{summary.encryptionLabel}</p>
</div>
<div>
<p className="text-xs uppercase text-muted-foreground">Notifications</p>
<p className="font-medium">{summary.notificationsLabel}</p>
</div>
</CardContent>
</Card>
</div>
<div className="pointer-events-auto absolute inset-0 z-20 flex cursor-not-allowed select-none flex-col items-center justify-center gap-6 bg-gradient-to-br from-background/95 via-background/80 to-background/40 px-6 text-center backdrop-blur-x">
<div className="inline-flex items-center gap-2 rounded-full border border-muted-foreground/30 bg-muted/40 px-4 py-1.5 text-xs font-semibold uppercase tracking-[0.35em] text-muted-foreground">
<span className="tracking-[0.2em]">Preview</span>
</div>
<div className="max-w-md space-y-3 text-balance">
<h3 className="text-2xl font-semibold">Automated backups are coming soon</h3>
<p className="text-sm text-muted-foreground">
We&apos;re working hard to bring robust backup and snapshot capabilities to Ironmount.
</p>
</div>
<div className="rounded-xl border border-dashed border-muted-foreground/30 bg-background/70 px-4 py-2 text-xs text-muted-foreground">
Coming soon stay tuned!
</div>
</div>
</div>
);
};

View File

@@ -1,146 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import { FolderOpen } from "lucide-react";
import { useCallback, useMemo, useState } from "react";
import { listFilesOptions } from "~/api-client/@tanstack/react-query.gen";
import { listFiles } from "~/api-client/sdk.gen";
import { FileTree } from "~/components/file-tree";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
import { parseError } from "~/lib/errors";
import type { Volume } from "~/lib/types";
type Props = {
volume: Volume;
};
interface FileEntry {
name: string;
path: string;
type: "file" | "directory";
size?: number;
modifiedAt?: number;
}
export const FilesTabContent = ({ volume }: Props) => {
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());
// Fetch root level files
const { data, isLoading, error } = useQuery({
...listFilesOptions({ path: { name: volume.name } }),
enabled: volume.status === "mounted",
refetchInterval: 10000,
});
useMemo(() => {
if (data?.files) {
setAllFiles((prev) => {
const next = new Map(prev);
for (const file of data.files) {
next.set(file.path, file);
}
return next;
});
}
}, [data]);
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 listFiles({
path: { name: volume.name },
query: { path: folderPath },
throwOnError: true,
});
if (result.data?.files) {
setAllFiles((prev) => {
const next = new Map(prev);
for (const file of result.data.files) {
next.set(file.path, file);
}
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;
});
}
}
},
[volume.name, fetchedFolders],
);
const fileArray = useMemo(() => Array.from(allFiles.values()), [allFiles]);
if (volume.status !== "mounted") {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center text-center py-12">
<FolderOpen className="mb-4 h-12 w-12 text-muted-foreground" />
<p className="text-muted-foreground">Volume must be mounted to browse files.</p>
<p className="text-sm text-muted-foreground mt-2">Mount the volume to explore its contents.</p>
</CardContent>
</Card>
);
}
return (
<Card className="h-[600px] flex flex-col">
<CardHeader>
<CardTitle>File Explorer</CardTitle>
<CardDescription>Browse the files and folders in this volume.</CardDescription>
</CardHeader>
<CardContent className="flex-1 overflow-hidden flex flex-col">
{isLoading && (
<div className="flex items-center justify-center h-full">
<p className="text-muted-foreground">Loading files...</p>
</div>
)}
{error && (
<div className="flex items-center justify-center h-full">
<p className="text-destructive">Failed to load files: {error.message}</p>
</div>
)}
{!isLoading && !error && (
<div className="overflow-auto flex-1 border rounded-md bg-card">
{fileArray.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-center p-8">
<FolderOpen className="mb-4 h-12 w-12 text-muted-foreground" />
<p className="text-muted-foreground">This volume is empty.</p>
<p className="text-sm text-muted-foreground mt-2">
Files and folders will appear here once you add them.
</p>
</div>
) : (
<FileTree
files={fileArray}
onFolderExpand={handleFolderExpand}
expandedFolders={expandedFolders}
loadingFolders={loadingFolders}
className="p-2"
/>
)}
</div>
)}
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,94 @@
import { useMutation } from "@tanstack/react-query";
import { RotateCcw } from "lucide-react";
import { useId, useState } from "react";
import { toast } from "sonner";
import { restoreSnapshotMutation } from "~/api-client/@tanstack/react-query.gen";
import { parseError } from "~/lib/errors";
import { Button } from "~/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "~/components/ui/dialog";
import { ScrollArea } from "~/components/ui/scroll-area";
import { RestoreSnapshotForm, type RestoreSnapshotFormValues } from "./restore-snapshot-form";
type Props = {
name: string;
snapshotId: string;
};
export const RestoreSnapshotDialog = ({ name, snapshotId }: Props) => {
const [open, setOpen] = useState(false);
const formId = useId();
const restore = useMutation({
...restoreSnapshotMutation(),
onSuccess: (data) => {
toast.success("Snapshot restored successfully", {
description: `${data.filesRestored} files restored, ${data.filesSkipped} files skipped`,
});
setOpen(false);
},
onError: (error) => {
toast.error("Failed to restore snapshot", {
description: parseError(error)?.message,
});
},
});
const handleSubmit = (values: RestoreSnapshotFormValues) => {
const include = values.include
?.split(",")
.map((s) => s.trim())
.filter(Boolean);
const exclude = values.exclude
?.split(",")
.map((s) => s.trim())
.filter(Boolean);
restore.mutate({
path: { name },
body: {
snapshotId,
include: include && include.length > 0 ? include : undefined,
exclude: exclude && exclude.length > 0 ? exclude : undefined,
},
});
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button>
<RotateCcw size={16} className="mr-2" />
Restore
</Button>
</DialogTrigger>
<DialogContent>
<ScrollArea className="max-h-[600px] p-4">
<DialogHeader>
<DialogTitle>Restore Snapshot</DialogTitle>
<DialogDescription>
Restore snapshot {snapshotId.substring(0, 8)} to a local filesystem path
</DialogDescription>
</DialogHeader>
<RestoreSnapshotForm className="mt-4" formId={formId} onSubmit={handleSubmit} />
<DialogFooter className="mt-4">
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button type="submit" form={formId} disabled={restore.isPending}>
{restore.isPending ? "Restoring..." : "Restore"}
</Button>
</DialogFooter>
</ScrollArea>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,89 @@
import { arktypeResolver } from "@hookform/resolvers/arktype";
import { type } from "arktype";
import { useForm } from "react-hook-form";
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "~/components/ui/form";
import { Input } from "~/components/ui/input";
const restoreSnapshotFormSchema = type({
path: "string?",
include: "string?",
exclude: "string?",
});
export type RestoreSnapshotFormValues = typeof restoreSnapshotFormSchema.inferIn;
type Props = {
formId: string;
onSubmit: (values: RestoreSnapshotFormValues) => void;
className?: string;
};
export const RestoreSnapshotForm = ({ formId, onSubmit, className }: Props) => {
const form = useForm<RestoreSnapshotFormValues>({
resolver: arktypeResolver(restoreSnapshotFormSchema),
defaultValues: {
path: "",
include: "",
exclude: "",
},
});
const handleSubmit = (values: RestoreSnapshotFormValues) => {
onSubmit(values);
};
return (
<Form {...form}>
<form id={formId} onSubmit={form.handleSubmit(handleSubmit)} className={className}>
<div className="space-y-4">
<FormField
control={form.control}
name="path"
render={({ field }) => (
<FormItem>
<FormLabel>Path (Optional)</FormLabel>
<FormControl>
<Input placeholder="/specific/path" {...field} />
</FormControl>
<FormDescription>
Restore only a specific path from the snapshot (leave empty to restore all)
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="include"
render={({ field }) => (
<FormItem>
<FormLabel>Include Patterns (Optional)</FormLabel>
<FormControl>
<Input placeholder="*.txt,/documents/**" {...field} />
</FormControl>
<FormDescription>Include only files matching these patterns (comma-separated)</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="exclude"
render={({ field }) => (
<FormItem>
<FormLabel>Exclude Patterns (Optional)</FormLabel>
<FormControl>
<Input placeholder="*.log,/temp/**" {...field} />
</FormControl>
<FormDescription>Exclude files matching these patterns (comma-separated)</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
</form>
</Form>
);
};

View File

@@ -0,0 +1,189 @@
import { useQuery } from "@tanstack/react-query";
import { Database, RotateCcw } from "lucide-react";
import { useState } from "react";
import { useNavigate } from "react-router";
import { listRepositories } from "~/api-client/sdk.gen";
import { listRepositoriesOptions } from "~/api-client/@tanstack/react-query.gen";
import { CreateRepositoryDialog } from "~/components/create-repository-dialog";
import { RepositoryIcon } from "~/components/repository-icon";
import { Button } from "~/components/ui/button";
import { Card } from "~/components/ui/card";
import { Input } from "~/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/components/ui/table";
import type { Route } from "./+types/repositories";
import { cn } from "~/lib/utils";
import { EmptyState } from "~/components/empty-state";
export function meta(_: Route.MetaArgs) {
return [
{ title: "Ironmount - Repositories" },
{
name: "description",
content: "Manage your backup repositories",
},
];
}
export const clientLoader = async () => {
const repositories = await listRepositories();
if (repositories.data) return repositories.data;
return [];
};
export default function Repositories({ loaderData }: Route.ComponentProps) {
const [searchQuery, setSearchQuery] = useState("");
const [statusFilter, setStatusFilter] = useState("");
const [backendFilter, setBackendFilter] = useState("");
const [createRepositoryOpen, setCreateRepositoryOpen] = useState(false);
const clearFilters = () => {
setSearchQuery("");
setStatusFilter("");
setBackendFilter("");
};
const navigate = useNavigate();
const { data } = useQuery({
...listRepositoriesOptions(),
initialData: loaderData,
refetchInterval: 10000,
refetchOnWindowFocus: true,
});
const filteredRepositories =
data?.filter((repository) => {
const matchesSearch = repository.name.toLowerCase().includes(searchQuery.toLowerCase());
const matchesStatus = !statusFilter || repository.status === statusFilter;
const matchesBackend = !backendFilter || repository.type === backendFilter;
return matchesSearch && matchesStatus && matchesBackend;
}) || [];
const hasNoRepositories = data?.length === 0;
const hasNoFilteredRepositories = filteredRepositories.length === 0 && !hasNoRepositories;
if (hasNoRepositories) {
return (
<EmptyState
icon={Database}
title="No repository"
description="Repositories are remote storage locations where you can backup your volumes securely. Encrypted and optimized for storage efficiency."
button={<CreateRepositoryDialog open={createRepositoryOpen} setOpen={setCreateRepositoryOpen} />}
/>
);
}
return (
<Card className="p-0 gap-0">
<div className="flex flex-col lg:flex-row items-stretch lg:items-center gap-2 md:justify-between p-4 bg-card-header py-4">
<span className="flex flex-col sm:flex-row items-stretch md:items-center gap-0 flex-wrap ">
<Input
className="w-full lg:w-[180px] min-w-[180px] mr-[-1px] mt-[-1px]"
placeholder="Search repositories…"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-full lg:w-[180px] min-w-[180px] mr-[-1px] mt-[-1px]">
<SelectValue placeholder="All status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="healthy">Healthy</SelectItem>
<SelectItem value="error">Error</SelectItem>
<SelectItem value="unknown">Unknown</SelectItem>
</SelectContent>
</Select>
<Select value={backendFilter} onValueChange={setBackendFilter}>
<SelectTrigger className="w-full lg:w-[180px] min-w-[180px] mt-[-1px]">
<SelectValue placeholder="All backends" />
</SelectTrigger>
<SelectContent>
<SelectItem value="local">Local</SelectItem>
<SelectItem value="sftp">SFTP</SelectItem>
<SelectItem value="s3">S3</SelectItem>
</SelectContent>
</Select>
{(searchQuery || statusFilter || backendFilter) && (
<Button onClick={clearFilters} className="w-full lg:w-auto mt-2 lg:mt-0 lg:ml-2">
<RotateCcw className="h-4 w-4 mr-2" />
Clear filters
</Button>
)}
</span>
<CreateRepositoryDialog open={createRepositoryOpen} setOpen={setCreateRepositoryOpen} />
</div>
<div className="overflow-x-auto">
<Table className="border-t">
<TableHeader className="bg-card-header">
<TableRow>
<TableHead className="w-[100px] uppercase">Name</TableHead>
<TableHead className="uppercase text-left">Backend</TableHead>
<TableHead className="uppercase hidden sm:table-cell">Compression</TableHead>
<TableHead className="uppercase text-center">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{hasNoFilteredRepositories ? (
<TableRow>
<TableCell colSpan={4} className="text-center py-12">
<div className="flex flex-col items-center gap-3">
<p className="text-muted-foreground">No repositories match your filters.</p>
<Button onClick={clearFilters} variant="outline" size="sm">
<RotateCcw className="h-4 w-4 mr-2" />
Clear filters
</Button>
</div>
</TableCell>
</TableRow>
) : (
filteredRepositories.map((repository) => (
<TableRow
key={repository.name}
className="hover:bg-accent/50 hover:cursor-pointer"
onClick={() => navigate(`/repositories/${repository.name}`)}
>
<TableCell className="font-medium text-strong-accent">{repository.name}</TableCell>
<TableCell>
<span className="flex items-center gap-2">
<RepositoryIcon backend={repository.type} />
{repository.type}
</span>
</TableCell>
<TableCell className="hidden sm:table-cell">
<span className="text-muted-foreground text-xs bg-primary/10 rounded-md px-2 py-1">
{repository.compressionMode || "off"}
</span>
</TableCell>
<TableCell className="text-center">
<span
className={cn(
"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": repository.status === "healthy",
"bg-red-500/10 text-red-500": repository.status === "error",
},
)}
>
{repository.status || "unknown"}
</span>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
<div className="px-4 py-2 text-sm text-muted-foreground bg-card-header flex justify-end border-t">
{hasNoFilteredRepositories ? (
"No repositories match filters."
) : (
<span>
<span className="text-strong-accent">{filteredRepositories.length}</span> repositor
{filteredRepositories.length === 1 ? "y" : "ies"}
</span>
)}
</div>
</Card>
);
}

View File

@@ -0,0 +1,152 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useNavigate, useParams, useSearchParams } from "react-router";
import { toast } from "sonner";
import { useState, useEffect } from "react";
import {
deleteRepositoryMutation,
getRepositoryOptions,
listSnapshotsOptions,
} from "~/api-client/@tanstack/react-query.gen";
import { Button } from "~/components/ui/button";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogHeader,
AlertDialogTitle,
} from "~/components/ui/alert-dialog";
import { parseError } from "~/lib/errors";
import { getRepository } from "~/api-client/sdk.gen";
import type { Route } from "./+types/repository-details";
import { cn } from "~/lib/utils";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
import { RepositoryInfoTabContent } from "../tabs/info";
import { RepositorySnapshotsTabContent } from "../tabs/snapshots";
export function meta({ params }: Route.MetaArgs) {
return [
{ title: `Ironmount - ${params.name}` },
{
name: "description",
content: "Manage your restic backup repositories with ease.",
},
];
}
export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => {
const repository = await getRepository({ path: { name: params.name ?? "" } });
if (repository.data) return repository.data;
};
export default function RepositoryDetailsPage({ loaderData }: Route.ComponentProps) {
const { name } = useParams<{ name: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [searchParams, setSearchParams] = useSearchParams();
const activeTab = searchParams.get("tab") || "info";
const { data } = useQuery({
...getRepositoryOptions({ path: { name: name ?? "" } }),
initialData: loaderData,
refetchInterval: 10000,
refetchOnWindowFocus: true,
});
useEffect(() => {
if (name) {
queryClient.prefetchQuery(listSnapshotsOptions({ path: { name } }));
}
}, [name, queryClient]);
const deleteRepo = useMutation({
...deleteRepositoryMutation(),
onSuccess: () => {
toast.success("Repository deleted successfully");
navigate("/repositories");
},
onError: (error) => {
toast.error("Failed to delete repository", {
description: parseError(error)?.message,
});
},
});
const handleConfirmDelete = () => {
setShowDeleteConfirm(false);
deleteRepo.mutate({ path: { name: name ?? "" } });
};
if (!name) {
return <div>Repository not found</div>;
}
if (!data) {
return <div>Loading...</div>;
}
return (
<>
<div className="flex items-center justify-between mb-4">
<div>
<div className="text-sm font-semibold mb-2 text-muted-foreground flex items-center gap-2">
<span
className={cn(
"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-red-500/10 text-red-500": data.status === "error",
},
)}
>
{data.status || "unknown"}
</span>
<span className="text-xs bg-primary/10 rounded-md px-2 py-1">{data.type}</span>
</div>
</div>
<div className="flex gap-4">
<Button variant="destructive" onClick={() => setShowDeleteConfirm(true)} disabled={deleteRepo.isPending}>
Delete
</Button>
</div>
</div>
<Tabs value={activeTab} onValueChange={(value) => setSearchParams({ tab: value })}>
<TabsList className="mb-2">
<TabsTrigger value="info">Configuration</TabsTrigger>
<TabsTrigger value="snapshots">Snapshots</TabsTrigger>
</TabsList>
<TabsContent value="info">
<RepositoryInfoTabContent repository={data} />
</TabsContent>
<TabsContent value="snapshots">
<RepositorySnapshotsTabContent repository={data} />
</TabsContent>
</Tabs>
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete repository?</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete the repository <strong>{name}</strong>? This action cannot be undone and
will remove all backup data.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="flex gap-3 justify-end">
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete repository
</AlertDialogAction>
</div>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View File

@@ -0,0 +1,87 @@
import { useQuery } from "@tanstack/react-query";
import { redirect, useParams } from "react-router";
import { listSnapshotFilesOptions } from "~/api-client/@tanstack/react-query.gen";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { RestoreSnapshotDialog } from "../components/restore-snapshot-dialog";
import { SnapshotFileBrowser } from "~/modules/backups/components/snapshot-file-browser";
import { getSnapshotDetails } from "~/api-client";
import type { Route } from "./+types/snapshot-details";
export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => {
const snapshot = await getSnapshotDetails({ path: { name: params.name, snapshotId: params.snapshotId } });
if (snapshot.data) return snapshot.data;
return redirect("/repositories");
};
export default function SnapshotDetailsPage({ loaderData }: Route.ComponentProps) {
const { name, snapshotId } = useParams<{ name: string; snapshotId: string }>();
const { data } = useQuery({
...listSnapshotFilesOptions({
path: { name: name ?? "", snapshotId: snapshotId ?? "" },
query: { path: "/" },
}),
enabled: !!name && !!snapshotId,
});
if (!name || !snapshotId) {
return (
<div className="flex items-center justify-center h-full">
<p className="text-destructive">Invalid snapshot reference</p>
</div>
);
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">{name}</h1>
<p className="text-sm text-muted-foreground">Snapshot: {snapshotId}</p>
</div>
<RestoreSnapshotDialog name={name} snapshotId={snapshotId} />
</div>
<SnapshotFileBrowser repositoryName={name} snapshot={loaderData} />
{data?.snapshot && (
<Card>
<CardHeader>
<CardTitle>Snapshot Information</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
<div className="grid grid-cols-2 gap-4">
<div>
<span className="text-muted-foreground">Snapshot ID:</span>
<p className="font-mono">{data.snapshot.id}</p>
</div>
<div>
<span className="text-muted-foreground">Short ID:</span>
<p className="font-mono">{data.snapshot.short_id}</p>
</div>
<div>
<span className="text-muted-foreground">Hostname:</span>
<p>{data.snapshot.hostname}</p>
</div>
<div>
<span className="text-muted-foreground">Time:</span>
<p>{new Date(data.snapshot.time).toLocaleString()}</p>
</div>
<div className="col-span-2">
<span className="text-muted-foreground">Paths:</span>
<div className="space-y-1 mt-1">
{data.snapshot.paths.map((path) => (
<p key={path} className="font-mono text-xs bg-muted px-2 py-1 rounded">
{path}
</p>
))}
</div>
</div>
</div>
</CardContent>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,62 @@
import { Card } from "~/components/ui/card";
import type { Repository } from "~/lib/types";
type Props = {
repository: Repository;
};
export const RepositoryInfoTabContent = ({ repository }: Props) => {
return (
<Card className="p-6">
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold mb-4">Repository Information</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<div className="text-sm font-medium text-muted-foreground">Name</div>
<p className="mt-1 text-sm">{repository.name}</p>
</div>
<div>
<div className="text-sm font-medium text-muted-foreground">Backend</div>
<p className="mt-1 text-sm">{repository.type}</p>
</div>
<div>
<div className="text-sm font-medium text-muted-foreground">Compression Mode</div>
<p className="mt-1 text-sm">{repository.compressionMode || "off"}</p>
</div>
<div>
<div className="text-sm font-medium text-muted-foreground">Status</div>
<p className="mt-1 text-sm">{repository.status || "unknown"}</p>
</div>
<div>
<div className="text-sm font-medium text-muted-foreground">Created At</div>
<p className="mt-1 text-sm">{new Date(repository.createdAt).toLocaleString()}</p>
</div>
<div>
<div className="text-sm font-medium text-muted-foreground">Last Checked</div>
<p className="mt-1 text-sm">
{repository.lastChecked ? new Date(repository.lastChecked).toLocaleString() : "Never"}
</p>
</div>
</div>
</div>
{repository.lastError && (
<div>
<h3 className="text-lg font-semibold mb-4 text-red-500">Last Error</h3>
<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>
</div>
</div>
)}
<div>
<h3 className="text-lg font-semibold mb-4">Configuration</h3>
<div className="bg-muted/50 rounded-md p-4">
<pre className="text-sm overflow-auto">{JSON.stringify(repository.config, null, 2)}</pre>
</div>
</div>
</div>
</Card>
);
};

View File

@@ -0,0 +1,174 @@
import { useQuery } from "@tanstack/react-query";
import { intervalToDuration } from "date-fns";
import { Database } from "lucide-react";
import { useState } from "react";
import { listSnapshotsOptions } from "~/api-client/@tanstack/react-query.gen";
import { ByteSize } from "~/components/bytes-size";
import { SnapshotsTable } from "~/components/snapshots-table";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
import { Input } from "~/components/ui/input";
import { Table, TableBody, TableCell, TableRow } from "~/components/ui/table";
import type { Repository, Snapshot } from "~/lib/types";
type Props = {
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) => {
const [searchQuery, setSearchQuery] = useState("");
const { data, isFetching, failureReason } = useQuery({
...listSnapshotsOptions({ path: { name: repository.name } }),
refetchInterval: 10000,
refetchOnWindowFocus: true,
initialData: [],
});
const filteredSnapshots = data.filter((snapshot: Snapshot) => {
if (!searchQuery) return true;
const searchLower = searchQuery.toLowerCase();
return (
snapshot.short_id.toLowerCase().includes(searchLower) ||
snapshot.paths.some((path) => path.toLowerCase().includes(searchLower))
);
});
const hasNoFilteredSnapshots = !filteredSnapshots?.length && !data.length;
if (repository.status === "error") {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center text-center py-12">
<Database className="mb-4 h-12 w-12 text-destructive" />
<p className="text-destructive font-semibold">Repository Error</p>
<p className="text-sm text-muted-foreground mt-2">
This repository is in an error state and cannot be accessed.
</p>
{repository.lastError && (
<div className="mt-4 max-w-md bg-destructive/10 border border-destructive/20 rounded-md p-3">
<p className="text-sm text-destructive">{repository.lastError}</p>
</div>
)}
</CardContent>
</Card>
);
}
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) {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center text-center py-12">
<Database className="mb-4 h-12 w-12 text-destructive" />
<p className="text-destructive font-semibold">Failed to Load Snapshots</p>
<p className="text-sm text-muted-foreground mt-2">{failureReason.message}</p>
</CardContent>
</Card>
);
}
if (!data.length) {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center text-center py-16 px-4">
<div className="relative mb-8">
<div className="absolute inset-0 animate-pulse">
<div className="w-32 h-32 rounded-full bg-primary/10 blur-2xl" />
</div>
<div className="relative flex items-center justify-center w-32 h-32 rounded-full bg-gradient-to-br from-primary/20 to-primary/5 border-2 border-primary/20">
<Database className="w-16 h-16 text-primary/70" strokeWidth={1.5} />
</div>
</div>
<div className="max-w-md space-y-3">
<h3 className="text-2xl font-semibold text-foreground">No snapshots yet</h3>
<p className="text-muted-foreground text-sm">
Snapshots are point-in-time backups of your data. Create your first backup to see it here.
</p>
</div>
</CardContent>
</Card>
);
}
return (
<Card className="p-0 gap-0">
<CardHeader className="p-4 bg-card-header">
<div className="flex flex-col lg:flex-row items-stretch lg:items-center gap-4 justify-between">
<div className="flex-1">
<CardTitle>Snapshots</CardTitle>
<CardDescription className="mt-1">
Backup snapshots stored in this repository. Total: {data.length}
</CardDescription>
</div>
<div className="flex gap-2 items-center">
<Input
className="w-full lg:w-[240px]"
placeholder="Search snapshots..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
</div>
</CardHeader>
{hasNoFilteredSnapshots ? (
<Table className="border-t">
<TableBody>
<TableRow>
<TableCell colSpan={5} className="text-center py-12">
<div className="flex flex-col items-center gap-3">
<p className="text-muted-foreground">No snapshots match your search.</p>
<Button onClick={() => setSearchQuery("")} variant="outline" size="sm">
Clear search
</Button>
</div>
</TableCell>
</TableRow>
</TableBody>
</Table>
) : (
<SnapshotsTable snapshots={filteredSnapshots} repositoryName={repository.name} />
)}
<div className="px-4 py-2 text-sm text-muted-foreground bg-card-header flex justify-between border-t">
<span>
{hasNoFilteredSnapshots
? "No snapshots match filters."
: `Showing ${filteredSnapshots.length} of ${data.length}`}
</span>
{!hasNoFilteredSnapshots && (
<span>
Total size:&nbsp;
<span className="text-strong-accent font-medium">
<ByteSize
bytes={filteredSnapshots.reduce((sum, s) => sum + s.size, 0)}
base={1024}
maximumFractionDigits={1}
/>
</span>
</span>
)}
</div>
</Card>
);
};

View File

@@ -35,7 +35,7 @@ export const HealthchecksCard = ({ volume }: Props) => {
...updateVolumeMutation(),
onSuccess: (d) => {
toast.success("Volume updated", {
description: `Auto remount is now ${d.volume.autoRemount ? "enabled" : "paused"}.`,
description: `Auto remount is now ${d.autoRemount ? "enabled" : "paused"}.`,
});
},
onError: (error) => {

View File

@@ -1,6 +1,7 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { useNavigate, useParams } from "react-router";
import { useNavigate, useParams, useSearchParams } from "react-router";
import { toast } from "sonner";
import { useState } from "react";
import {
deleteVolumeMutation,
getVolumeOptions,
@@ -10,15 +11,23 @@ import {
import { StatusDot } from "~/components/status-dot";
import { Button } from "~/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogHeader,
AlertDialogTitle,
} from "~/components/ui/alert-dialog";
import { VolumeIcon } from "~/components/volume-icon";
import { parseError } from "~/lib/errors";
import { cn } from "~/lib/utils";
import { VolumeBackupsTabContent } from "~/modules/details/tabs/backups";
import { DockerTabContent } from "~/modules/details/tabs/docker";
import { FilesTabContent } from "~/modules/details/tabs/files";
import { VolumeInfoTabContent } from "~/modules/details/tabs/info";
import { getVolume } from "../api-client";
import type { Route } from "./+types/details";
import type { Route } from "./+types/volume-details";
import { getVolume } from "~/api-client";
import { VolumeInfoTabContent } from "../tabs/info";
import { FilesTabContent } from "../tabs/files";
import { DockerTabContent } from "../tabs/docker";
export function meta({ params }: Route.MetaArgs) {
return [
@@ -35,9 +44,12 @@ export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => {
if (volume.data) return volume.data;
};
export default function DetailsPage({ loaderData }: Route.ComponentProps) {
export default function VolumeDetails({ loaderData }: Route.ComponentProps) {
const { name } = useParams<{ name: string }>();
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const activeTab = searchParams.get("tab") || "info";
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const { data } = useQuery({
...getVolumeOptions({ path: { name: name ?? "" } }),
@@ -50,7 +62,7 @@ export default function DetailsPage({ loaderData }: Route.ComponentProps) {
...deleteVolumeMutation(),
onSuccess: () => {
toast.success("Volume deleted successfully");
navigate("/");
navigate("/volumes");
},
onError: (error) => {
toast.error("Failed to delete volume", {
@@ -83,10 +95,9 @@ export default function DetailsPage({ loaderData }: Route.ComponentProps) {
},
});
const handleDeleteConfirm = (name: string) => {
if (confirm(`Are you sure you want to delete the volume "${name}"? This action cannot be undone.`)) {
deleteVol.mutate({ path: { name } });
}
const handleConfirmDelete = () => {
setShowDeleteConfirm(false);
deleteVol.mutate({ path: { name: name ?? "" } });
};
if (!name) {
@@ -126,17 +137,16 @@ export default function DetailsPage({ loaderData }: Route.ComponentProps) {
>
Unmount
</Button>
<Button variant="destructive" onClick={() => handleDeleteConfirm(name)} disabled={deleteVol.isPending}>
<Button variant="destructive" onClick={() => setShowDeleteConfirm(true)} disabled={deleteVol.isPending}>
Delete
</Button>
</div>
</div>
<Tabs defaultValue="info" className="mt-4">
<Tabs value={activeTab} onValueChange={(value) => setSearchParams({ tab: value })} className="mt-4">
<TabsList className="mb-2">
<TabsTrigger value="info">Configuration</TabsTrigger>
<TabsTrigger value="files">Files</TabsTrigger>
<TabsTrigger value="docker">Docker</TabsTrigger>
<TabsTrigger value="backups">Backups</TabsTrigger>
</TabsList>
<TabsContent value="info">
<VolumeInfoTabContent volume={volume} statfs={statfs} />
@@ -147,10 +157,27 @@ export default function DetailsPage({ loaderData }: Route.ComponentProps) {
<TabsContent value="docker">
<DockerTabContent volume={volume} />
</TabsContent>
<TabsContent value="backups">
<VolumeBackupsTabContent volume={volume} />
</TabsContent>
</Tabs>
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete volume?</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete the volume <strong>{name}</strong>? This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="flex gap-3 justify-end">
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete volume
</AlertDialogAction>
</div>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View File

@@ -1,5 +1,5 @@
import { useQuery } from "@tanstack/react-query";
import { Copy, RotateCcw } from "lucide-react";
import { HardDrive, RotateCcw } from "lucide-react";
import { useState } from "react";
import { useNavigate } from "react-router";
import { listVolumes } from "~/api-client";
@@ -13,7 +13,7 @@ import { Input } from "~/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/components/ui/table";
import { VolumeIcon } from "~/components/volume-icon";
import type { Route } from "./+types/home";
import type { Route } from "./+types/volumes";
export function meta(_: Route.MetaArgs) {
return [
@@ -27,11 +27,11 @@ export function meta(_: Route.MetaArgs) {
export const clientLoader = async () => {
const volumes = await listVolumes();
if (volumes.data) return { volumes: volumes.data.volumes };
return { volumes: [] };
if (volumes.data) return volumes.data;
return [];
};
export default function Home({ loaderData }: Route.ComponentProps) {
export default function Volumes({ loaderData }: Route.ComponentProps) {
const [createVolumeOpen, setCreateVolumeOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [statusFilter, setStatusFilter] = useState("");
@@ -53,21 +53,24 @@ export default function Home({ loaderData }: Route.ComponentProps) {
});
const filteredVolumes =
data?.volumes.filter((volume) => {
data.filter((volume) => {
const matchesSearch = volume.name.toLowerCase().includes(searchQuery.toLowerCase());
const matchesStatus = !statusFilter || volume.status === statusFilter;
const matchesBackend = !backendFilter || volume.type === backendFilter;
return matchesSearch && matchesStatus && matchesBackend;
}) || [];
const hasNoVolumes = data?.volumes.length === 0;
const hasNoVolumes = data.length === 0;
const hasNoFilteredVolumes = filteredVolumes.length === 0 && !hasNoVolumes;
if (hasNoVolumes) {
return (
<Card className="p-0 gap-0">
<EmptyState />
</Card>
<EmptyState
icon={HardDrive}
title="No volume"
description="Manage and monitor all your storage backends in one place with advanced features like automatic mounting and health checks."
button={<CreateVolumeDialog open={createVolumeOpen} setOpen={setCreateVolumeOpen} />}
/>
);
}
@@ -116,7 +119,6 @@ export default function Home({ loaderData }: Route.ComponentProps) {
<TableRow>
<TableHead className="w-[100px] uppercase">Name</TableHead>
<TableHead className="uppercase text-left">Backend</TableHead>
<TableHead className="uppercase hidden sm:table-cell">Mountpoint</TableHead>
<TableHead className="uppercase text-center">Status</TableHead>
</TableRow>
</TableHeader>
@@ -144,14 +146,6 @@ export default function Home({ loaderData }: Route.ComponentProps) {
<TableCell>
<VolumeIcon backend={volume.type} />
</TableCell>
<TableCell className="hidden sm:table-cell">
<span className="flex items-center gap-2">
<span className="text-muted-foreground text-xs truncate bg-primary/10 rounded-md px-2 py-1">
{volume.path}
</span>
<Copy size={10} />
</span>
</TableCell>
<TableCell className="text-center">
<StatusDot status={volume.status} />
</TableCell>

View File

@@ -38,7 +38,7 @@ export const DockerTabContent = ({ volume }: Props) => {
refetchOnWindowFocus: true,
});
const containers = containersData?.containers || [];
const containers = containersData || [];
const getStateClass = (state: string) => {
switch (state) {

View File

@@ -0,0 +1,41 @@
import { FolderOpen } from "lucide-react";
import { VolumeFileBrowser } from "~/components/volume-file-browser";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
import type { Volume } from "~/lib/types";
type Props = {
volume: Volume;
};
export const FilesTabContent = ({ volume }: Props) => {
if (volume.status !== "mounted") {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center text-center py-12">
<FolderOpen className="mb-4 h-12 w-12 text-muted-foreground" />
<p className="text-muted-foreground">Volume must be mounted to browse files.</p>
<p className="text-sm text-muted-foreground mt-2">Mount the volume to explore its contents.</p>
</CardContent>
</Card>
);
}
return (
<Card className="h-[600px] flex flex-col">
<CardHeader>
<CardTitle>File Explorer</CardTitle>
<CardDescription>Browse the files and folders in this volume.</CardDescription>
</CardHeader>
<CardContent className="flex-1 overflow-hidden flex flex-col">
<VolumeFileBrowser
volumeName={volume.name}
enabled={volume.status === "mounted"}
refetchInterval={10000}
className="overflow-auto flex-1 border rounded-md bg-card p-2"
emptyMessage="This volume is empty."
emptyDescription="Files and folders will appear here once you add them."
/>
</CardContent>
</Card>
);
};

View File

@@ -42,7 +42,6 @@ export const VolumeInfoTabContent = ({ volume, statfs }: Props) => {
const [pendingValues, setPendingValues] = useState<FormValues | null>(null);
const handleSubmit = (values: FormValues) => {
console.log({ values });
setPendingValues(values);
setOpen(true);
};

View File

@@ -45,7 +45,7 @@ export function Layout({ children }: { children: React.ReactNode }) {
<Links />
</head>
<QueryClientProvider client={queryClient}>
<body className="min-h-dvh">
<body>
{children}
<Toaster />
<ScrollRestoration />

View File

@@ -1,7 +1,17 @@
import { index, layout, type RouteConfig, route } from "@react-router/dev/routes";
import { layout, type RouteConfig, route } from "@react-router/dev/routes";
export default [
route("onboarding", "./routes/onboarding.tsx"),
route("login", "./routes/login.tsx"),
layout("./components/layout.tsx", [index("./routes/home.tsx"), route("volumes/:name", "./routes/details.tsx")]),
route("onboarding", "./modules/auth/routes/onboarding.tsx"),
route("login", "./modules/auth/routes/login.tsx"),
layout("./components/layout.tsx", [
route("/", "./routes/root.tsx"),
route("volumes", "./modules/volumes/routes/volumes.tsx"),
route("volumes/:name", "./modules/volumes/routes/volume-details.tsx"),
route("backups", "./modules/backups/routes/backups.tsx"),
route("backups/create", "./modules/backups/routes/create-backup.tsx"),
route("backups/:id", "./modules/backups/routes/backup-details.tsx"),
route("repositories", "./modules/repositories/routes/repositories.tsx"),
route("repositories/:name", "./modules/repositories/routes/repository-details.tsx"),
route("repositories/:name/:snapshotId", "./modules/repositories/routes/snapshot-details.tsx"),
]),
] satisfies RouteConfig;

View File

@@ -1,103 +0,0 @@
import { arktypeResolver } from "@hookform/resolvers/arktype";
import { useMutation } from "@tanstack/react-query";
import { type } from "arktype";
import { useForm } from "react-hook-form";
import { useNavigate } from "react-router";
import { toast } from "sonner";
import { loginMutation } from "~/api-client/@tanstack/react-query.gen";
import { GridBackground } from "~/components/grid-background";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "~/components/ui/form";
import { Input } from "~/components/ui/input";
const loginSchema = type({
username: "2<=string<=50",
password: "string>=1",
});
type LoginFormValues = typeof loginSchema.inferIn;
export default function LoginPage() {
const navigate = useNavigate();
const form = useForm<LoginFormValues>({
resolver: arktypeResolver(loginSchema),
defaultValues: {
username: "",
password: "",
},
});
const login = useMutation({
...loginMutation(),
onSuccess: async () => {
navigate("/");
},
onError: (error) => {
console.error(error);
toast.error("Login failed");
},
});
const onSubmit = (values: LoginFormValues) => {
login.mutate({
body: {
username: values.username.trim(),
password: values.password.trim(),
},
});
};
return (
<GridBackground className="flex items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle className="text-2xl font-bold">Welcome Back</CardTitle>
<CardDescription>Sign in to your account</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input
{...field}
type="text"
placeholder="Enter your username"
disabled={login.isPending}
autoFocus
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input {...field} type="password" placeholder="Enter your password" disabled={login.isPending} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="w-full" loading={login.isPending}>
Sign In
</Button>
</form>
</Form>
</CardContent>
</Card>
</GridBackground>
);
}

View File

@@ -1,133 +0,0 @@
import { arktypeResolver } from "@hookform/resolvers/arktype";
import { useMutation } from "@tanstack/react-query";
import { type } from "arktype";
import { useForm } from "react-hook-form";
import { useNavigate } from "react-router";
import { toast } from "sonner";
import { registerMutation } from "~/api-client/@tanstack/react-query.gen";
import { GridBackground } from "~/components/grid-background";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "~/components/ui/form";
import { Input } from "~/components/ui/input";
const onboardingSchema = type({
username: "2<=string<=50",
password: "string>=8",
confirmPassword: "string>=1",
});
type OnboardingFormValues = typeof onboardingSchema.inferIn;
export default function OnboardingPage() {
const navigate = useNavigate();
const form = useForm<OnboardingFormValues>({
resolver: arktypeResolver(onboardingSchema),
defaultValues: {
username: "",
password: "",
confirmPassword: "",
},
});
const registerUser = useMutation({
...registerMutation(),
onSuccess: async () => {
toast.success("Admin user created successfully!");
navigate("/");
},
onError: (error) => {
console.error(error);
toast.error("Failed to create admin user");
},
});
const onSubmit = (values: OnboardingFormValues) => {
if (values.password !== values.confirmPassword) {
form.setError("confirmPassword", {
type: "manual",
message: "Passwords do not match",
});
return;
}
registerUser.mutate({
body: {
username: values.username.trim(),
password: values.password.trim(),
},
});
};
return (
<GridBackground className="flex items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle className="text-2xl font-bold">Welcome to Ironmount</CardTitle>
<CardDescription>Create the admin user to get started</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input {...field} type="text" placeholder="admin" disabled={registerUser.isPending} autoFocus />
</FormControl>
<FormDescription>Choose a username for the admin account</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input
{...field}
type="password"
placeholder="Enter a secure password"
disabled={registerUser.isPending}
/>
</FormControl>
<FormDescription>Password must be at least 8 characters long.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="confirmPassword"
render={({ field }) => (
<FormItem>
<FormLabel>Confirm Password</FormLabel>
<FormControl>
<Input
{...field}
type="password"
placeholder="Re-enter your password"
disabled={registerUser.isPending}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="w-full" loading={registerUser.isPending}>
Create Admin User
</Button>
</form>
</Form>
</CardContent>
</Card>
</GridBackground>
);
}

View File

@@ -0,0 +1,5 @@
import { redirect } from "react-router";
export const clientLoader = async () => {
return redirect("/volumes");
};

View File

@@ -1,12 +1,12 @@
export function deepClean<T>(obj: T): T {
if (Array.isArray(obj)) {
return obj.map(deepClean).filter((v) => v !== undefined && v !== null) as T;
return obj.map(deepClean).filter((v) => v !== undefined && v !== null && v !== "") as T;
}
if (obj && typeof obj === "object") {
return Object.entries(obj).reduce((acc, [key, value]) => {
const cleaned = deepClean(value);
if (cleaned !== undefined) acc[key as keyof T] = cleaned;
if (cleaned !== undefined && cleaned !== "") acc[key as keyof T] = cleaned;
return acc;
}, {} as T);
}

View File

@@ -0,0 +1,17 @@
export const getCronExpression = (frequency: string, dailyTime?: string, weeklyDay?: string): string => {
if (frequency === "hourly") {
return "0 * * * *";
}
if (!dailyTime) {
dailyTime = "02:00";
}
const [hours, minutes] = dailyTime.split(":");
if (frequency === "daily") {
return `${minutes} ${hours} * * *`;
}
return `${minutes} ${hours} * * ${weeklyDay ?? "0"}`;
};

View File

@@ -12,10 +12,12 @@
"@hookform/resolvers": "^5.2.2",
"@ironmount/schemas": "workspace:*",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
@@ -28,9 +30,11 @@
"arktype": "^2.1.23",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cron-parser": "^5.4.0",
"date-fns": "^4.1.0",
"dither-plugin": "^1.1.1",
"isbot": "^5.1.31",
"lucide-react": "^0.544.0",
"lucide-react": "^0.546.0",
"next-themes": "^0.4.6",
"react": "^19.2.0",
"react-dom": "^19.2.0",

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

View File

@@ -0,0 +1,15 @@
CREATE TABLE `repositories_table` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`backend` text NOT NULL,
`config` text NOT NULL,
`compression_mode` text DEFAULT 'auto',
`status` text DEFAULT 'unknown',
`last_checked` integer,
`last_error` text,
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
`updated_at` integer DEFAULT (unixepoch()) NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `repositories_table_name_unique` ON `repositories_table` (`name`);--> statement-breakpoint
ALTER TABLE `volumes_table` DROP COLUMN `path`;

View File

@@ -0,0 +1 @@
ALTER TABLE `repositories_table` RENAME COLUMN "backend" TO "type";

View File

@@ -0,0 +1,20 @@
CREATE TABLE IF NOT EXISTS `backup_schedules_table` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`volume_id` integer NOT NULL,
`repository_id` text NOT NULL,
`enabled` integer DEFAULT true NOT NULL,
`cron_expression` text NOT NULL,
`retention_policy` text,
`exclude_patterns` text DEFAULT '[]',
`include_patterns` text DEFAULT '[]',
`last_backup_at` integer,
`last_backup_status` text,
`last_backup_error` text,
`next_backup_at` integer,
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
`updated_at` integer DEFAULT (unixepoch()) NOT NULL,
FOREIGN KEY (`volume_id`) REFERENCES `volumes_table`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`repository_id`) REFERENCES `repositories_table`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS `backup_schedules_table_volume_id_unique` ON `backup_schedules_table` (`volume_id`);

View File

@@ -0,0 +1 @@
DROP INDEX `backup_schedules_table_volume_id_unique`;

View File

@@ -0,0 +1,311 @@
{
"version": "6",
"dialect": "sqlite",
"id": "16f360b6-fb61-44f3-a7f7-2bae78ebf7ca",
"prevId": "75f0aac0-aa63-4577-bfb6-4638a008935f",
"tables": {
"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
},
"backend": {
"name": "backend",
"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
},
"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": {}
}
}

View File

@@ -0,0 +1,313 @@
{
"version": "6",
"dialect": "sqlite",
"id": "866b1d3b-454b-4cf7-9835-a0f60d048b6e",
"prevId": "16f360b6-fb61-44f3-a7f7-2bae78ebf7ca",
"tables": {
"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
},
"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": {
"\"repositories_table\".\"backend\"": "\"repositories_table\".\"type\""
}
},
"internal": {
"indexes": {}
}
}

View File

@@ -0,0 +1,459 @@
{
"version": "6",
"dialect": "sqlite",
"id": "6e35f329-5431-47fd-8862-8fb06b0afe4b",
"prevId": "866b1d3b-454b-4cf7-9835-a0f60d048b6e",
"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": {
"backup_schedules_table_volume_id_unique": {
"name": "backup_schedules_table_volume_id_unique",
"columns": [
"volume_id"
],
"isUnique": true
}
},
"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
},
"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": {}
}
}

View File

@@ -0,0 +1,451 @@
{
"version": "6",
"dialect": "sqlite",
"id": "6a326ac0-cb3a-4c63-8800-bc86d18e0c1d",
"prevId": "6e35f329-5431-47fd-8862-8fb06b0afe4b",
"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
},
"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": {}
}
}

View File

@@ -43,6 +43,34 @@
"when": 1759416698274,
"tag": "0005_simple_alice",
"breakpoints": true
},
{
"idx": 6,
"version": "6",
"when": 1760734377440,
"tag": "0006_secret_micromacro",
"breakpoints": true
},
{
"idx": 7,
"version": "6",
"when": 1761224911352,
"tag": "0007_watery_sersi",
"breakpoints": true
},
{
"idx": 8,
"version": "6",
"when": 1761414054481,
"tag": "0008_silent_lady_bullseye",
"breakpoints": true
},
{
"idx": 9,
"version": "6",
"when": 1762095226041,
"tag": "0009_little_adam_warlock",
"breakpoints": true
}
]
}

View File

@@ -4,16 +4,14 @@
"scripts": {
"dev": "bun run --watch src/index.ts",
"build": "rm -rf dist && bun build.ts",
"tsc": "tsc --noEmit",
"gen:migrations": "drizzle-kit generate",
"studio": "drizzle-kit studio"
"tsc": "tsc --noEmit"
},
"dependencies": {
"@hono/arktype-validator": "^2.0.1",
"@hono/standard-validator": "^0.1.5",
"@ironmount/schemas": "workspace:*",
"@scalar/hono-api-reference": "^0.9.13",
"arktype": "^2.1.20",
"arktype": "^2.1.23",
"cron-parser": "^5.4.0",
"dockerode": "^4.0.8",
"dotenv": "^17.2.1",
"drizzle-orm": "^0.44.6",
@@ -27,7 +25,6 @@
"devDependencies": {
"@libsql/client": "^0.15.15",
"@types/bun": "^1.3.0",
"@types/dockerode": "^3.3.44",
"drizzle-kit": "^0.31.5"
"@types/dockerode": "^3.3.44"
}
}

View File

@@ -1,4 +1,5 @@
export const OPERATION_TIMEOUT = 5000;
export const VOLUME_MOUNT_BASE = "/var/lib/docker/volumes/ironmount";
export const VOLUME_MOUNT_BASE = "/var/lib/ironmount";
export const REPOSITORY_BASE = "/var/lib/repositories";
export const DATABASE_URL = "/data/ironmount.db";
export const RESTIC_PASS_FILE = "/data/secrets/restic.pass";

View File

@@ -0,0 +1,44 @@
import cron, { type ScheduledTask } from "node-cron";
import { logger } from "../utils/logger";
export abstract class Job {
abstract run(): Promise<unknown>;
}
type JobConstructor = new () => Job;
class SchedulerClass {
private tasks: ScheduledTask[] = [];
async start() {
logger.info("Scheduler started");
}
build(JobClass: JobConstructor) {
const job = new JobClass();
return {
schedule: (cronExpression: string) => {
const task = cron.schedule(cronExpression, async () => {
try {
await job.run();
} catch (error) {
logger.error(`Job ${JobClass.name} failed:`, error);
}
});
this.tasks.push(task);
logger.info(`Scheduled job ${JobClass.name} with cron: ${cronExpression}`);
},
};
}
async stop() {
for (const task of this.tasks) {
task.stop();
}
this.tasks = [];
logger.info("Scheduler stopped");
}
}
export const Scheduler = new SchedulerClass();

View File

@@ -7,6 +7,7 @@ import { DATABASE_URL } from "../core/constants";
import * as schema from "./schema";
const sqlite = new Database(DATABASE_URL);
sqlite.run("PRAGMA foreign_keys = ON;");
export const db = drizzle({ client: sqlite, schema });

View File

@@ -1,63 +1,107 @@
import type { BackendStatus, BackendType, volumeConfigSchema } from "@ironmount/schemas";
import type {
BackendStatus,
BackendType,
CompressionMode,
RepositoryBackend,
RepositoryStatus,
repositoryConfigSchema,
volumeConfigSchema,
} from "@ironmount/schemas";
import { sql } from "drizzle-orm";
import { int, sqliteTable, text } from "drizzle-orm/sqlite-core";
RepositoryStatus,
} from "@ironmount/schemas/restic";
import { relations, sql } from "drizzle-orm";
import { int, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
/**
* Volumes Table
*/
export const volumesTable = sqliteTable("volumes_table", {
id: int().primaryKey({ autoIncrement: true }),
name: text().notNull().unique(),
path: text().notNull(),
type: text().$type<BackendType>().notNull(),
status: text().$type<BackendStatus>().notNull().default("unmounted"),
lastError: text("last_error"),
lastHealthCheck: int("last_health_check", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`),
createdAt: int("created_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`),
updatedAt: int("updated_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`),
lastHealthCheck: integer("last_health_check", { mode: "number" }).notNull().default(sql`(unixepoch())`),
createdAt: integer("created_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
updatedAt: integer("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
config: text("config", { mode: "json" }).$type<typeof volumeConfigSchema.inferOut>().notNull(),
autoRemount: int("auto_remount", { mode: "boolean" }).notNull().default(true),
});
export type Volume = typeof volumesTable.$inferSelect;
/**
* Users Table
*/
export const usersTable = sqliteTable("users_table", {
id: int().primaryKey({ autoIncrement: true }),
username: text().notNull().unique(),
passwordHash: text("password_hash").notNull(),
createdAt: int("created_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`),
updatedAt: int("updated_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`),
createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
updatedAt: int("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
});
export type User = typeof usersTable.$inferSelect;
export const sessionsTable = sqliteTable("sessions_table", {
id: text().primaryKey(),
userId: int("user_id")
.notNull()
.references(() => usersTable.id, { onDelete: "cascade" }),
expiresAt: int("expires_at", { mode: "timestamp" }).notNull(),
createdAt: int("created_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`),
expiresAt: int("expires_at", { mode: "number" }).notNull(),
createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
});
export type Session = typeof sessionsTable.$inferSelect;
/**
* Repositories Table
*/
export const repositoriesTable = sqliteTable("repositories_table", {
id: text().primaryKey(),
name: text().notNull().unique(),
backend: text().$type<RepositoryBackend>().notNull(),
type: text().$type<RepositoryBackend>().notNull(),
config: text("config", { mode: "json" }).$type<typeof repositoryConfigSchema.inferOut>().notNull(),
compressionMode: text("compression_mode").$type<CompressionMode>().default("auto"),
status: text().$type<RepositoryStatus>().default("unknown"),
lastChecked: int("last_checked", { mode: "timestamp" }),
lastChecked: int("last_checked", { mode: "number" }),
lastError: text("last_error"),
createdAt: int("created_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`),
updatedAt: int("updated_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`),
createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
updatedAt: int("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
});
export type Repository = typeof repositoriesTable.$inferSelect;
/**
* Backup Schedules Table
*/
export const backupSchedulesTable = sqliteTable("backup_schedules_table", {
id: int().primaryKey({ autoIncrement: true }),
volumeId: int("volume_id")
.notNull()
.references(() => volumesTable.id, { onDelete: "cascade" }),
repositoryId: text("repository_id")
.notNull()
.references(() => repositoriesTable.id, { onDelete: "cascade" }),
enabled: int("enabled", { mode: "boolean" }).notNull().default(true),
cronExpression: text("cron_expression").notNull(),
retentionPolicy: text("retention_policy", { mode: "json" }).$type<{
keepLast?: number;
keepHourly?: number;
keepDaily?: number;
keepWeekly?: number;
keepMonthly?: number;
keepYearly?: number;
keepWithinDuration?: string;
}>(),
excludePatterns: text("exclude_patterns", { mode: "json" }).$type<string[]>().default([]),
includePatterns: text("include_patterns", { mode: "json" }).$type<string[]>().default([]),
lastBackupAt: int("last_backup_at", { mode: "number" }),
lastBackupStatus: text("last_backup_status").$type<"success" | "error">(),
lastBackupError: text("last_backup_error"),
nextBackupAt: int("next_backup_at", { mode: "number" }),
createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
updatedAt: int("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
});
export const backupScheduleRelations = relations(backupSchedulesTable, ({ one }) => ({
volume: one(volumesTable, {
fields: [backupSchedulesTable.volumeId],
references: [volumesTable.id],
}),
repository: one(repositoriesTable, {
fields: [backupSchedulesTable.repositoryId],
references: [repositoriesTable.id],
}),
}));
export type BackupSchedule = typeof backupSchedulesTable.$inferSelect;

View File

@@ -9,7 +9,9 @@ import { authController } from "./modules/auth/auth.controller";
import { requireAuth } from "./modules/auth/auth.middleware";
import { driverController } from "./modules/driver/driver.controller";
import { startup } from "./modules/lifecycle/startup";
import { repositoriesController } from "./modules/repositories/repositories.controller";
import { volumeController } from "./modules/volumes/volume.controller";
import { backupScheduleController } from "./modules/backups/backups.controller";
import { handleServiceError } from "./utils/errors";
import { logger } from "./utils/logger";
@@ -21,7 +23,7 @@ export const generalDescriptor = (app: Hono) =>
version: "1.0.0",
description: "API for managing volumes",
},
servers: [{ url: "http://localhost:4096", description: "Development Server" }],
servers: [{ url: "http://192.168.2.42:4096", description: "Development Server" }],
},
});
@@ -37,6 +39,8 @@ const app = new Hono()
.get("healthcheck", (c) => c.json({ status: "ok" }))
.route("/api/v1/auth", authController.basePath("/api/v1"))
.route("/api/v1/volumes", volumeController.use(requireAuth))
.route("/api/v1/repositories", repositoriesController.use(requireAuth))
.route("/api/v1/backups", backupScheduleController.use(requireAuth))
.get("/assets/*", serveStatic({ root: "./assets/frontend" }))
.get("*", serveStatic({ path: "./assets/frontend/index.html" }));

View File

@@ -0,0 +1,29 @@
import { Job } from "../core/scheduler";
import { backupsService } from "../modules/backups/backups.service";
import { toMessage } from "../utils/errors";
import { logger } from "../utils/logger";
export class BackupExecutionJob extends Job {
async run() {
logger.debug("Checking for backup schedules to execute...");
const scheduleIds = await backupsService.getSchedulesToExecute();
if (scheduleIds.length === 0) {
logger.debug("No backup schedules to execute");
return { done: true, timestamp: new Date(), executed: 0 };
}
logger.info(`Found ${scheduleIds.length} backup schedule(s) to execute`);
for (const scheduleId of scheduleIds) {
try {
await backupsService.executeBackup(scheduleId);
} catch (error) {
logger.error(`Failed to execute backup for schedule ${scheduleId}: ${toMessage(error)}`);
}
}
return { done: true, timestamp: new Date(), executed: scheduleIds.length };
}
}

View File

@@ -0,0 +1,49 @@
import { Job } from "../core/scheduler";
import path from "node:path";
import fs from "node:fs/promises";
import { volumeService } from "../modules/volumes/volume.service";
import { readMountInfo } from "../utils/mountinfo";
import { getVolumePath } from "../modules/volumes/helpers";
import { logger } from "../utils/logger";
import { executeUnmount } from "../modules/backends/utils/backend-utils";
import { toMessage } from "../utils/errors";
import { VOLUME_MOUNT_BASE } from "../core/constants";
export class CleanupDanglingMountsJob extends Job {
async run() {
const allVolumes = await volumeService.listVolumes();
const allSystemMounts = await readMountInfo();
for (const mount of allSystemMounts) {
if (mount.mountPoint.includes("ironmount") && mount.mountPoint.endsWith("_data")) {
const matchingVolume = allVolumes.find((v) => getVolumePath(v.name) === mount.mountPoint);
if (!matchingVolume) {
logger.info(`Found dangling mount at ${mount.mountPoint}, attempting to unmount...`);
await executeUnmount(mount.mountPoint);
await fs.rmdir(path.dirname(mount.mountPoint)).catch((err) => {
logger.warn(
`Failed to remove dangling mount directory ${path.dirname(mount.mountPoint)}: ${toMessage(err)}`,
);
});
}
}
}
const allIronmountDirs = await fs.readdir(VOLUME_MOUNT_BASE).catch(() => []);
for (const dir of allIronmountDirs) {
const volumePath = getVolumePath(dir);
const matchingVolume = allVolumes.find((v) => getVolumePath(v.name) === volumePath);
if (!matchingVolume) {
const fullPath = path.join(VOLUME_MOUNT_BASE, dir);
logger.info(`Found dangling mount directory at ${fullPath}, attempting to remove...`);
await fs.rmdir(fullPath, { recursive: true }).catch((err) => {
logger.warn(`Failed to remove dangling mount directory ${fullPath}: ${toMessage(err)}`);
});
}
}
return { done: true, timestamp: new Date() };
}
}

View File

@@ -0,0 +1,10 @@
import { Job } from "../core/scheduler";
import { authService } from "../modules/auth/auth.service";
export class CleanupSessionsJob extends Job {
async run() {
authService.cleanupExpiredSessions();
return { done: true, timestamp: new Date() };
}
}

View File

@@ -0,0 +1,25 @@
import { Job } from "../core/scheduler";
import { volumeService } from "../modules/volumes/volume.service";
import { logger } from "../utils/logger";
import { db } from "../db/db";
import { eq, or } from "drizzle-orm";
import { volumesTable } from "../db/schema";
export class VolumeHealthCheckJob extends Job {
async run() {
logger.debug("Running health check for all volumes...");
const volumes = await db.query.volumesTable.findMany({
where: or(eq(volumesTable.status, "mounted"), eq(volumesTable.status, "error")),
});
for (const volume of volumes) {
const { status } = await volumeService.checkHealth(volume.name);
if (status === "error" && volume.autoRemount) {
await volumeService.mountVolume(volume.name);
}
}
return { done: true, timestamp: new Date() };
}
}

View File

@@ -10,8 +10,14 @@ import {
logoutDto,
registerBodySchema,
registerDto,
type GetMeDto,
type GetStatusDto,
type LoginDto,
type LogoutDto,
type RegisterDto,
} from "./auth.dto";
import { authService } from "./auth.service";
import { toMessage } from "../../utils/errors";
const COOKIE_NAME = "session_id";
const COOKIE_OPTIONS = {
@@ -33,9 +39,12 @@ export const authController = new Hono()
expires: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
});
return c.json({ message: "User registered successfully", user: { id: user.id, username: user.username } }, 201);
return c.json<RegisterDto>(
{ success: true, message: "User registered successfully", user: { id: user.id, username: user.username } },
201,
);
} catch (error) {
return c.json({ message: error instanceof Error ? error.message : "Registration failed" }, 400);
return c.json<RegisterDto>({ success: false, message: toMessage(error) }, 400);
}
})
.post("/login", loginDto, validator("json", loginBodySchema), async (c) => {
@@ -46,15 +55,16 @@ export const authController = new Hono()
setCookie(c, COOKIE_NAME, sessionId, {
...COOKIE_OPTIONS,
expires: expiresAt,
expires: new Date(expiresAt),
});
return c.json({
return c.json<LoginDto>({
success: true,
message: "Login successful",
user: { id: user.id, username: user.username },
});
} catch (error) {
return c.json({ message: error instanceof Error ? error.message : "Login failed" }, 401);
return c.json<LoginDto>({ success: false, message: toMessage(error) }, 401);
}
})
.post("/logout", logoutDto, async (c) => {
@@ -65,13 +75,13 @@ export const authController = new Hono()
deleteCookie(c, COOKIE_NAME, COOKIE_OPTIONS);
}
return c.json({ message: "Logout successful" });
return c.json<LogoutDto>({ success: true });
})
.get("/me", getMeDto, async (c) => {
const sessionId = getCookie(c, COOKIE_NAME);
if (!sessionId) {
return c.json({ message: "Not authenticated" }, 401);
return c.json<GetMeDto>({ success: false, message: "Not authenticated" }, 401);
}
const session = await authService.verifySession(sessionId);
@@ -81,11 +91,13 @@ export const authController = new Hono()
return c.json({ message: "Not authenticated" }, 401);
}
return c.json({
return c.json<GetMeDto>({
success: true,
user: session.user,
message: "Authenticated",
});
})
.get("/status", getStatusDto, async (c) => {
const hasUsers = await authService.hasUsers();
return c.json({ hasUsers });
return c.json<GetStatusDto>({ hasUsers });
});

View File

@@ -14,10 +14,11 @@ export const registerBodySchema = type({
const loginResponseSchema = type({
message: "string",
success: "boolean",
user: type({
id: "string",
id: "number",
username: "string",
}),
}).optional(),
});
export const loginDto = describeRoute({
@@ -39,6 +40,8 @@ export const loginDto = describeRoute({
},
});
export type LoginDto = typeof loginResponseSchema.infer;
export const registerDto = describeRoute({
description: "Register a new user",
operationId: "register",
@@ -58,6 +61,12 @@ export const registerDto = describeRoute({
},
});
export type RegisterDto = typeof loginResponseSchema.infer;
const logoutResponseSchema = type({
success: "boolean",
});
export const logoutDto = describeRoute({
description: "Logout current user",
operationId: "logout",
@@ -67,13 +76,15 @@ export const logoutDto = describeRoute({
description: "Logout successful",
content: {
"application/json": {
schema: resolver(type({ message: "string" })),
schema: resolver(logoutResponseSchema),
},
},
},
},
});
export type LogoutDto = typeof logoutResponseSchema.infer;
export const getMeDto = describeRoute({
description: "Get current authenticated user",
operationId: "getMe",
@@ -87,12 +98,11 @@ export const getMeDto = describeRoute({
},
},
},
401: {
description: "Not authenticated",
},
},
});
export type GetMeDto = typeof loginResponseSchema.infer;
const statusResponseSchema = type({
hasUsers: "boolean",
});
@@ -113,5 +123,7 @@ export const getStatusDto = describeRoute({
},
});
export type GetStatusDto = typeof statusResponseSchema.infer;
export type LoginBody = typeof loginBodySchema.infer;
export type RegisterBody = typeof registerBodySchema.infer;

View File

@@ -1,4 +1,4 @@
import { eq } from "drizzle-orm";
import { eq, lt } from "drizzle-orm";
import { db } from "../../db/db";
import { sessionsTable, usersTable } from "../../db/schema";
import { logger } from "../../utils/logger";
@@ -30,7 +30,7 @@ export class AuthService {
logger.info(`User registered: ${username}`);
const sessionId = crypto.randomUUID();
const expiresAt = new Date(Date.now() + SESSION_DURATION);
const expiresAt = new Date(Date.now() + SESSION_DURATION).getTime();
await db.insert(sessionsTable).values({
id: sessionId,
@@ -58,7 +58,7 @@ export class AuthService {
}
const sessionId = crypto.randomUUID();
const expiresAt = new Date(Date.now() + SESSION_DURATION);
const expiresAt = new Date(Date.now() + SESSION_DURATION).getTime();
await db.insert(sessionsTable).values({
id: sessionId,
@@ -100,7 +100,7 @@ export class AuthService {
return null;
}
if (session.session.expiresAt < new Date()) {
if (session.session.expiresAt < Date.now()) {
await db.delete(sessionsTable).where(eq(sessionsTable.id, sessionId));
return null;
}
@@ -121,7 +121,7 @@ export class AuthService {
* Clean up expired sessions
*/
async cleanupExpiredSessions() {
const result = await db.delete(sessionsTable).where(eq(sessionsTable.expiresAt, new Date())).returning();
const result = await db.delete(sessionsTable).where(lt(sessionsTable.expiresAt, Date.now())).returning();
if (result.length > 0) {
logger.info(`Cleaned up ${result.length} expired sessions`);
}

View File

@@ -1,6 +1,6 @@
import type { BackendStatus } from "@ironmount/schemas";
import { VOLUME_MOUNT_BASE } from "../../core/constants";
import type { Volume } from "../../db/schema";
import { getVolumePath } from "../volumes/helpers";
import { makeDirectoryBackend } from "./directory/directory-backend";
import { makeNfsBackend } from "./nfs/nfs-backend";
import { makeSmbBackend } from "./smb/smb-backend";
@@ -18,7 +18,7 @@ export type VolumeBackend = {
};
export const createVolumeBackend = (volume: Volume): VolumeBackend => {
const path = `${VOLUME_MOUNT_BASE}/${volume.name}/_data`;
const path = getVolumePath(volume.name);
switch (volume.config.backend) {
case "nfs": {

View File

@@ -1,12 +1,12 @@
import * as fs from "node:fs/promises";
import * as os from "node:os";
import { BACKEND_STATUS, type BackendConfig } from "@ironmount/schemas";
import type { VolumeBackend } from "../backend";
import { logger } from "../../../utils/logger";
import { withTimeout } from "../../../utils/timeout";
import { OPERATION_TIMEOUT } from "../../../core/constants";
import { toMessage } from "../../../utils/errors";
import { logger } from "../../../utils/logger";
import { getMountForPath } from "../../../utils/mountinfo";
import { withTimeout } from "../../../utils/timeout";
import type { VolumeBackend } from "../backend";
import { createTestFile, executeMount, executeUnmount } from "../utils/backend-utils";
const mount = async (config: BackendConfig, path: string) => {

View File

@@ -1,18 +1,31 @@
import * as npath from "node:path";
import * as fs from "node:fs/promises";
import { execFile as execFileCb } from "node:child_process";
import * as fs from "node:fs/promises";
import * as npath from "node:path";
import { promisify } from "node:util";
import { OPERATION_TIMEOUT } from "../../../core/constants";
import { logger } from "../../../utils/logger";
import { toMessage } from "../../../utils/errors";
import { logger } from "../../../utils/logger";
import { access, constants } from "node:fs/promises";
const execFile = promisify(execFileCb);
export const executeMount = async (args: string[]): Promise<void> => {
const { stderr } = await execFile("mount", args, {
timeout: OPERATION_TIMEOUT,
maxBuffer: 1024 * 1024,
});
let stderr: string | undefined;
try {
await access("/host/proc", constants.F_OK);
const result = await execFile("nsenter", ["--mount=/host/proc/1/ns/mnt", "mount", ...args], {
timeout: OPERATION_TIMEOUT,
maxBuffer: 1024 * 1024,
});
stderr = result.stderr;
} catch (_) {
const result = await execFile("mount", args, {
timeout: OPERATION_TIMEOUT,
maxBuffer: 1024 * 1024,
});
stderr = result.stderr;
}
if (stderr?.trim()) {
logger.warn(stderr.trim());
@@ -20,10 +33,22 @@ export const executeMount = async (args: string[]): Promise<void> => {
};
export const executeUnmount = async (path: string): Promise<void> => {
const { stderr } = await execFile("umount", ["-l", "-f", path], {
timeout: OPERATION_TIMEOUT,
maxBuffer: 1024 * 1024,
});
let stderr: string | undefined;
try {
await access("/host/proc", constants.F_OK);
const result = await execFile("nsenter", ["--mount=/host/proc/1/ns/mnt", "umount", "-l", "-f", path], {
timeout: OPERATION_TIMEOUT,
maxBuffer: 1024 * 1024,
});
stderr = result.stderr;
} catch (_) {
const result = await execFile("umount", ["-l", "-f", path], {
timeout: OPERATION_TIMEOUT,
maxBuffer: 1024 * 1024,
});
stderr = result.stderr;
}
if (stderr?.trim()) {
logger.warn(stderr.trim());

View File

@@ -139,8 +139,6 @@ const checkHealth = async (path: string) => {
const mount = await getMountForPath(path);
console.log(mount);
if (!mount || mount.fstype !== "fuse") {
throw new Error(`Path ${path} is not mounted as WebDAV.`);
}

View File

@@ -0,0 +1,72 @@
import { Hono } from "hono";
import { validator } from "hono-openapi";
import {
createBackupScheduleBody,
createBackupScheduleDto,
deleteBackupScheduleDto,
getBackupScheduleDto,
getBackupScheduleForVolumeDto,
listBackupSchedulesDto,
runBackupNowDto,
updateBackupScheduleDto,
updateBackupScheduleBody,
type CreateBackupScheduleDto,
type DeleteBackupScheduleDto,
type GetBackupScheduleDto,
type GetBackupScheduleForVolumeResponseDto,
type ListBackupSchedulesResponseDto,
type RunBackupNowDto,
type UpdateBackupScheduleDto,
} from "./backups.dto";
import { backupsService } from "./backups.service";
export const backupScheduleController = new Hono()
.get("/", listBackupSchedulesDto, async (c) => {
const schedules = await backupsService.listSchedules();
return c.json<ListBackupSchedulesResponseDto>(schedules, 200);
})
.get("/:scheduleId", getBackupScheduleDto, async (c) => {
const scheduleId = c.req.param("scheduleId");
const schedule = await backupsService.getSchedule(Number(scheduleId));
return c.json<GetBackupScheduleDto>(schedule, 200);
})
.get("/volume/:volumeId", getBackupScheduleForVolumeDto, async (c) => {
const volumeId = c.req.param("volumeId");
const schedule = await backupsService.getScheduleForVolume(Number(volumeId));
return c.json<GetBackupScheduleForVolumeResponseDto>(schedule, 200);
})
.post("/", createBackupScheduleDto, validator("json", createBackupScheduleBody), async (c) => {
const body = c.req.valid("json");
const schedule = await backupsService.createSchedule(body);
return c.json<CreateBackupScheduleDto>(schedule, 201);
})
.patch("/:scheduleId", updateBackupScheduleDto, validator("json", updateBackupScheduleBody), async (c) => {
const scheduleId = c.req.param("scheduleId");
const body = c.req.valid("json");
const schedule = await backupsService.updateSchedule(Number(scheduleId), body);
return c.json<UpdateBackupScheduleDto>(schedule, 200);
})
.delete("/:scheduleId", deleteBackupScheduleDto, async (c) => {
const scheduleId = c.req.param("scheduleId");
await backupsService.deleteSchedule(Number(scheduleId));
return c.json<DeleteBackupScheduleDto>({ success: true }, 200);
})
.post("/:scheduleId/run", runBackupNowDto, async (c) => {
const scheduleId = c.req.param("scheduleId");
backupsService.executeBackup(Number(scheduleId), true).catch((error) => {
console.error("Backup execution failed:", error);
});
return c.json<RunBackupNowDto>({ success: true }, 200);
});

View File

@@ -0,0 +1,225 @@
import { type } from "arktype";
import { describeRoute, resolver } from "hono-openapi";
import { volumeSchema } from "../volumes/volume.dto";
import { repositorySchema } from "../repositories/repositories.dto";
const retentionPolicySchema = type({
keepLast: "number?",
keepHourly: "number?",
keepDaily: "number?",
keepWeekly: "number?",
keepMonthly: "number?",
keepYearly: "number?",
keepWithinDuration: "string?",
});
export type RetentionPolicy = typeof retentionPolicySchema.infer;
const backupScheduleSchema = type({
id: "number",
volumeId: "number",
repositoryId: "string",
enabled: "boolean",
cronExpression: "string",
retentionPolicy: retentionPolicySchema.or("null"),
excludePatterns: "string[] | null",
includePatterns: "string[] | null",
lastBackupAt: "number | null",
lastBackupStatus: "'success' | 'error' | null",
lastBackupError: "string | null",
nextBackupAt: "number | null",
createdAt: "number",
updatedAt: "number",
}).and(
type({
volume: volumeSchema,
repository: repositorySchema,
}),
);
/**
* List all backup schedules
*/
export const listBackupSchedulesResponse = backupScheduleSchema.array();
export type ListBackupSchedulesResponseDto = typeof listBackupSchedulesResponse.infer;
export const listBackupSchedulesDto = describeRoute({
description: "List all backup schedules",
tags: ["Backups"],
operationId: "listBackupSchedules",
responses: {
200: {
description: "List of backup schedules",
content: {
"application/json": {
schema: resolver(listBackupSchedulesResponse),
},
},
},
},
});
/**
* Get a single backup schedule
*/
export const getBackupScheduleResponse = backupScheduleSchema;
export type GetBackupScheduleDto = typeof getBackupScheduleResponse.infer;
export const getBackupScheduleDto = describeRoute({
description: "Get a backup schedule by ID",
tags: ["Backups"],
operationId: "getBackupSchedule",
responses: {
200: {
description: "Backup schedule details",
content: {
"application/json": {
schema: resolver(getBackupScheduleResponse),
},
},
},
},
});
export const getBackupScheduleForVolumeResponse = backupScheduleSchema.or("null");
export type GetBackupScheduleForVolumeResponseDto = typeof getBackupScheduleForVolumeResponse.infer;
export const getBackupScheduleForVolumeDto = describeRoute({
description: "Get a backup schedule for a specific volume",
tags: ["Backups"],
operationId: "getBackupScheduleForVolume",
responses: {
200: {
description: "Backup schedule details for the volume",
content: {
"application/json": {
schema: resolver(getBackupScheduleForVolumeResponse),
},
},
},
},
});
/**
* Create a new backup schedule
*/
export const createBackupScheduleBody = type({
volumeId: "number",
repositoryId: "string",
enabled: "boolean",
cronExpression: "string",
retentionPolicy: retentionPolicySchema.optional(),
excludePatterns: "string[]?",
includePatterns: "string[]?",
tags: "string[]?",
});
export type CreateBackupScheduleBody = typeof createBackupScheduleBody.infer;
export const createBackupScheduleResponse = backupScheduleSchema.omit("volume", "repository");
export type CreateBackupScheduleDto = typeof createBackupScheduleResponse.infer;
export const createBackupScheduleDto = describeRoute({
description: "Create a new backup schedule for a volume",
operationId: "createBackupSchedule",
tags: ["Backups"],
responses: {
201: {
description: "Backup schedule created successfully",
content: {
"application/json": {
schema: resolver(createBackupScheduleResponse),
},
},
},
},
});
/**
* Update a backup schedule
*/
export const updateBackupScheduleBody = type({
repositoryId: "string",
enabled: "boolean?",
cronExpression: "string",
retentionPolicy: retentionPolicySchema.optional(),
excludePatterns: "string[]?",
includePatterns: "string[]?",
tags: "string[]?",
});
export type UpdateBackupScheduleBody = typeof updateBackupScheduleBody.infer;
export const updateBackupScheduleResponse = backupScheduleSchema.omit("volume", "repository");
export type UpdateBackupScheduleDto = typeof updateBackupScheduleResponse.infer;
export const updateBackupScheduleDto = describeRoute({
description: "Update a backup schedule",
operationId: "updateBackupSchedule",
tags: ["Backups"],
responses: {
200: {
description: "Backup schedule updated successfully",
content: {
"application/json": {
schema: resolver(updateBackupScheduleResponse),
},
},
},
},
});
/**
* Delete a backup schedule
*/
export const deleteBackupScheduleResponse = type({
success: "boolean",
});
export type DeleteBackupScheduleDto = typeof deleteBackupScheduleResponse.infer;
export const deleteBackupScheduleDto = describeRoute({
description: "Delete a backup schedule",
operationId: "deleteBackupSchedule",
tags: ["Backups"],
responses: {
200: {
description: "Backup schedule deleted successfully",
content: {
"application/json": {
schema: resolver(deleteBackupScheduleResponse),
},
},
},
},
});
/**
* Run a backup immediately
*/
export const runBackupNowResponse = type({
success: "boolean",
});
export type RunBackupNowDto = typeof runBackupNowResponse.infer;
export const runBackupNowDto = describeRoute({
description: "Trigger a backup immediately for a schedule",
operationId: "runBackupNow",
tags: ["Backups"],
responses: {
200: {
description: "Backup started successfully",
content: {
"application/json": {
schema: resolver(runBackupNowResponse),
},
},
},
},
});

View File

@@ -0,0 +1,274 @@
import { eq } from "drizzle-orm";
import cron from "node-cron";
import { CronExpressionParser } from "cron-parser";
import { NotFoundError, BadRequestError } from "http-errors-enhanced";
import { db } from "../../db/db";
import { backupSchedulesTable, repositoriesTable, volumesTable } from "../../db/schema";
import { restic } from "../../utils/restic";
import { logger } from "../../utils/logger";
import { getVolumePath } from "../volumes/helpers";
import type { CreateBackupScheduleBody, UpdateBackupScheduleBody } from "./backups.dto";
import { toMessage } from "../../utils/errors";
const calculateNextRun = (cronExpression: string): number => {
try {
const interval = CronExpressionParser.parse(cronExpression, {
currentDate: new Date(),
tz: "UTC",
});
return interval.next().getTime();
} catch (error) {
logger.error(`Failed to parse cron expression "${cronExpression}": ${error}`);
const fallback = new Date();
fallback.setMinutes(fallback.getMinutes() + 1);
return fallback.getTime();
}
};
const listSchedules = async () => {
const schedules = await db.query.backupSchedulesTable.findMany({
with: {
volume: true,
repository: true,
},
});
return schedules;
};
const getSchedule = async (scheduleId: number) => {
const schedule = await db.query.backupSchedulesTable.findFirst({
where: eq(volumesTable.id, scheduleId),
with: {
volume: true,
repository: true,
},
});
if (!schedule) {
throw new NotFoundError("Backup schedule not found");
}
return schedule;
};
const createSchedule = async (data: CreateBackupScheduleBody) => {
if (!cron.validate(data.cronExpression)) {
throw new BadRequestError("Invalid cron expression");
}
const volume = await db.query.volumesTable.findFirst({
where: eq(volumesTable.id, data.volumeId),
});
if (!volume) {
throw new NotFoundError("Volume not found");
}
const repository = await db.query.repositoriesTable.findFirst({
where: eq(repositoriesTable.id, data.repositoryId),
});
if (!repository) {
throw new NotFoundError("Repository not found");
}
const nextBackupAt = calculateNextRun(data.cronExpression);
const [newSchedule] = await db
.insert(backupSchedulesTable)
.values({
volumeId: data.volumeId,
repositoryId: data.repositoryId,
enabled: data.enabled,
cronExpression: data.cronExpression,
retentionPolicy: data.retentionPolicy ?? null,
excludePatterns: data.excludePatterns ?? [],
includePatterns: data.includePatterns ?? [],
nextBackupAt: nextBackupAt,
})
.returning();
if (!newSchedule) {
throw new Error("Failed to create backup schedule");
}
return newSchedule;
};
const updateSchedule = async (scheduleId: number, data: UpdateBackupScheduleBody) => {
const schedule = await db.query.backupSchedulesTable.findFirst({
where: eq(backupSchedulesTable.id, scheduleId),
});
if (!schedule) {
throw new NotFoundError("Backup schedule not found");
}
if (data.cronExpression && !cron.validate(data.cronExpression)) {
throw new BadRequestError("Invalid cron expression");
}
const repository = await db.query.repositoriesTable.findFirst({
where: eq(repositoriesTable.id, data.repositoryId),
});
if (!repository) {
throw new NotFoundError("Repository not found");
}
const cronExpression = data.cronExpression ?? schedule.cronExpression;
const nextBackupAt = data.cronExpression ? calculateNextRun(cronExpression) : schedule.nextBackupAt;
const [updated] = await db
.update(backupSchedulesTable)
.set({ ...data, nextBackupAt, updatedAt: Date.now() })
.where(eq(backupSchedulesTable.id, scheduleId))
.returning();
if (!updated) {
throw new Error("Failed to update backup schedule");
}
return updated;
};
const deleteSchedule = 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.delete(backupSchedulesTable).where(eq(backupSchedulesTable.id, scheduleId));
};
const executeBackup = async (scheduleId: number, manual = false) => {
const schedule = await db.query.backupSchedulesTable.findFirst({
where: eq(backupSchedulesTable.id, scheduleId),
});
if (!schedule) {
throw new NotFoundError("Backup schedule not found");
}
if (!schedule.enabled && !manual) {
logger.info(`Backup schedule ${scheduleId} is disabled. Skipping execution.`);
return;
}
const volume = await db.query.volumesTable.findFirst({
where: eq(volumesTable.id, schedule.volumeId),
});
if (!volume) {
throw new NotFoundError("Volume not found");
}
const repository = await db.query.repositoriesTable.findFirst({
where: eq(repositoriesTable.id, schedule.repositoryId),
});
if (!repository) {
throw new NotFoundError("Repository not found");
}
if (volume.status !== "mounted") {
throw new BadRequestError("Volume is not mounted");
}
logger.info(`Starting backup for volume ${volume.name} to repository ${repository.name}`);
try {
const volumePath = getVolumePath(volume.name);
const backupOptions: {
exclude?: string[];
include?: string[];
tags?: string[];
} = {
tags: [schedule.id.toString()],
};
if (schedule.excludePatterns && schedule.excludePatterns.length > 0) {
backupOptions.exclude = schedule.excludePatterns;
}
if (schedule.includePatterns && schedule.includePatterns.length > 0) {
backupOptions.include = schedule.includePatterns;
}
await restic.backup(repository.config, volumePath, backupOptions);
if (schedule.retentionPolicy) {
await restic.forget(repository.config, schedule.retentionPolicy, { tag: schedule.id.toString() });
}
const nextBackupAt = calculateNextRun(schedule.cronExpression);
await db
.update(backupSchedulesTable)
.set({
lastBackupAt: Date.now(),
lastBackupStatus: "success",
lastBackupError: null,
nextBackupAt: nextBackupAt,
updatedAt: Date.now(),
})
.where(eq(backupSchedulesTable.id, scheduleId));
logger.info(`Backup completed successfully for volume ${volume.name} to repository ${repository.name}`);
} catch (error) {
logger.error(`Backup failed for volume ${volume.name} to repository ${repository.name}: ${toMessage(error)}`);
await db
.update(backupSchedulesTable)
.set({
lastBackupAt: Date.now(),
lastBackupStatus: "error",
lastBackupError: toMessage(error),
updatedAt: Date.now(),
})
.where(eq(backupSchedulesTable.id, scheduleId));
throw error;
}
};
const getSchedulesToExecute = async () => {
const now = Date.now();
const schedules = await db.query.backupSchedulesTable.findMany({
where: eq(backupSchedulesTable.enabled, true),
});
const schedulesToRun: number[] = [];
for (const schedule of schedules) {
if (!schedule.nextBackupAt || schedule.nextBackupAt <= now) {
schedulesToRun.push(schedule.id);
}
}
return schedulesToRun;
};
const getScheduleForVolume = async (volumeId: number) => {
const schedule = await db.query.backupSchedulesTable.findFirst({
where: eq(backupSchedulesTable.volumeId, volumeId),
with: { volume: true, repository: true },
});
return schedule ?? null;
};
export const backupsService = {
listSchedules,
getSchedule,
createSchedule,
updateSchedule,
deleteSchedule,
executeBackup,
getSchedulesToExecute,
getScheduleForVolume,
};

Some files were not shown because too many files have changed in this diff Show More