Compare commits

...

79 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
Nicolas Meienberger
ae592481af feat: base restic repo schemas 2025-10-17 21:03:13 +02:00
Nicolas Meienberger
65a7f436fe fix: clean undefined values before posting form 2025-10-17 21:01:32 +02:00
Nicolas Meienberger
8af0bac63b feat: restic pass file generation 2025-10-17 13:15:24 +02:00
Nicolas Meienberger
41756e087a feat: report an issue button 2025-10-15 22:16:48 +02:00
Nicolas Meienberger
71ca5d3309 chore: bump bun to 1.3.0 2025-10-15 22:16:40 +02:00
Nico
e29908757f Revise warning message in README
Updated warning message to encourage feature requests.
2025-10-10 19:05:45 +02:00
Nicolas Meienberger
15f0dc637d chore: improve logging
Update README with version warning

Update Ironmount image version to v0.2.0
2025-10-09 22:41:56 +02:00
Nicolas Meienberger
d16be6cbca ci: create releases 2025-10-06 19:49:44 +02:00
Nico
1e3419c250 feat: file explorer (#1)
* feat: list volume files backend

* feat: file tree component

* feat: load sub folders

* fix: filetree wrong opening order

* temp: open / close icons

* chore: remove all hc files when cleaning

* chore: file-tree optimizations
2025-10-06 19:46:49 +02:00
Nicolas Meienberger
a5e0fb6aa2 docs: update README 2025-10-04 14:50:39 +02:00
Nicolas Meienberger
3d87814aee ui: empty state 2025-10-04 14:43:29 +02:00
Nicolas Meienberger
56a4afdc92 chore: remove prismjs 2025-10-04 14:20:36 +02:00
Nicolas Meienberger
728cfebeb7 refactor: extract grid background 2025-10-04 14:02:34 +02:00
Nicolas Meienberger
472f7799a4 ui: redesign tabs 2025-10-04 13:26:34 +02:00
Nicolas Meienberger
e134d0e1d1 ui: redesign 2025-10-04 00:16:47 +02:00
Nicolas Meienberger
689f14dff7 ui: card corner accent 2025-10-03 23:11:14 +02:00
Nicolas Meienberger
8d46074bb1 fix: issue with the test connection message 2025-10-03 21:50:57 +02:00
134 changed files with 11555 additions and 1918 deletions

View File

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

View File

@@ -74,3 +74,23 @@ jobs:
push: true push: true
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
publish-release:
runs-on: ubuntu-latest
needs: [build-images]
outputs:
id: ${{ steps.create_release.outputs.id }}
steps:
- name: Create GitHub release
id: create_release
uses: softprops/action-gh-release@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
body: |
**${{ needs.determine-release-type.outputs.tagname }}**
tag_name: ${{ needs.determine-release-type.outputs.tagname }}
name: ${{ needs.determine-release-type.outputs.tagname }}
draft: false
prerelease: true
files: cli/runtipi-cli-*

4
.gitignore vendored
View File

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

View File

@@ -1,18 +1,42 @@
ARG BUN_VERSION="1.2.23" 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=1.6.1-r2 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 # DEVELOPMENT
# ------------------------------ # ------------------------------
FROM runner_base AS development FROM base AS development
ENV NODE_ENV="development" ENV NODE_ENV="development"
WORKDIR /app WORKDIR /app
COPY --from=deps /deps/restic /usr/local/bin/restic
COPY ./package.json ./bun.lock ./ COPY ./package.json ./bun.lock ./
COPY ./packages/schemas/package.json ./packages/schemas/package.json COPY ./packages/schemas/package.json ./packages/schemas/package.json
COPY ./apps/client/package.json ./apps/client/package.json COPY ./apps/client/package.json ./apps/client/package.json
@@ -45,20 +69,21 @@ COPY . .
RUN bun run build RUN bun run build
FROM runner_base AS production FROM base AS production
ENV NODE_ENV="production" ENV NODE_ENV="production"
# RUN bun i ssh2
RUN apk add --no-cache davfs2=1.6.1-r2
WORKDIR /app WORKDIR /app
COPY --from=deps /deps/restic /usr/local/bin/restic
COPY --from=builder /app/apps/server/dist ./ COPY --from=builder /app/apps/server/dist ./
COPY --from=builder /app/apps/server/drizzle ./assets/migrations COPY --from=builder /app/apps/server/drizzle ./assets/migrations
COPY --from=builder /app/apps/client/dist/client ./assets/frontend 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"] 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

@@ -15,7 +15,8 @@
</figure> </figure>
</div> </div>
<br /> > [!WARNING]
> Ironmount is still in version 0.x.x and is subject to major changes from version to version. I am developing the core features and collecting feedbacks. Expect bugs! Please open issues or feature requests
## Intro ## Intro
@@ -41,11 +42,10 @@ In order to run Ironmount, you need to have Docker and Docker Compose installed
```yaml ```yaml
services: services:
ironmount: ironmount:
image: ghcr.io/nicotsx/ironmount:v0.0.1 image: ghcr.io/nicotsx/ironmount:v0.4.0
container_name: ironmount container_name: ironmount
restart: unless-stopped restart: unless-stopped
cap_add: privileged: true
- SYS_ADMIN
ports: ports:
- "4096:4096" - "4096:4096"
devices: devices:
@@ -53,7 +53,9 @@ services:
volumes: volumes:
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
- /run/docker/plugins:/run/docker/plugins - /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 - ironmount_data:/data
volumes: volumes:
@@ -73,6 +75,17 @@ Once the container is running, you can access the web interface at `http://<your
![Preview](https://github.com/nicotsx/ironmount/blob/main/screenshots/docker-instructions.png?raw=true) ![Preview](https://github.com/nicotsx/ironmount/blob/main/screenshots/docker-instructions.png?raw=true)
## Volume creation ## Third-Party Software
![Preview](https://github.com/nicotsx/ironmount/blob/main/screenshots/volume-creation.png?raw=true) 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

@@ -17,6 +17,22 @@ import {
mountVolume, mountVolume,
unmountVolume, unmountVolume,
healthCheckVolume, healthCheckVolume,
listFiles,
listRepositories,
createRepository,
deleteRepository,
getRepository,
listSnapshots,
getSnapshotDetails,
listSnapshotFiles,
restoreSnapshot,
listBackupSchedules,
createBackupSchedule,
deleteBackupSchedule,
getBackupSchedule,
updateBackupSchedule,
getBackupScheduleForVolume,
runBackupNow,
} from "../sdk.gen"; } from "../sdk.gen";
import { queryOptions, type UseMutationOptions, type DefaultError } from "@tanstack/react-query"; import { queryOptions, type UseMutationOptions, type DefaultError } from "@tanstack/react-query";
import type { import type {
@@ -45,6 +61,29 @@ import type {
UnmountVolumeResponse, UnmountVolumeResponse,
HealthCheckVolumeData, HealthCheckVolumeData,
HealthCheckVolumeResponse, 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"; } from "../types.gen";
import { client as _heyApiClient } from "../client.gen"; import { client as _heyApiClient } from "../client.gen";
@@ -539,3 +578,417 @@ export const healthCheckVolumeMutation = (
}; };
return mutationOptions; return mutationOptions;
}; };
export const listFilesQueryKey = (options: Options<ListFilesData>) => createQueryKey("listFiles", options);
/**
* List files in a volume directory
*/
export const listFilesOptions = (options: Options<ListFilesData>) => {
return queryOptions({
queryFn: async ({ queryKey, signal }) => {
const { data } = await listFiles({
...options,
...queryKey[0],
signal,
throwOnError: true,
});
return data;
},
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( export const client = createClient(
createConfig<ClientOptions>({ createConfig<ClientOptions>({
baseUrl: "http://localhost:4096", baseUrl: "http://192.168.2.42:4096",
}), }),
); );

View File

@@ -12,7 +12,6 @@ import type {
LogoutResponses, LogoutResponses,
GetMeData, GetMeData,
GetMeResponses, GetMeResponses,
GetMeErrors,
GetStatusData, GetStatusData,
GetStatusResponses, GetStatusResponses,
ListVolumesData, ListVolumesData,
@@ -34,13 +33,43 @@ import type {
GetContainersUsingVolumeErrors, GetContainersUsingVolumeErrors,
MountVolumeData, MountVolumeData,
MountVolumeResponses, MountVolumeResponses,
MountVolumeErrors,
UnmountVolumeData, UnmountVolumeData,
UnmountVolumeResponses, UnmountVolumeResponses,
UnmountVolumeErrors,
HealthCheckVolumeData, HealthCheckVolumeData,
HealthCheckVolumeResponses, HealthCheckVolumeResponses,
HealthCheckVolumeErrors, HealthCheckVolumeErrors,
ListFilesData,
ListFilesResponses,
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"; } from "./types.gen";
import { client as _heyApiClient } from "./client.gen"; import { client as _heyApiClient } from "./client.gen";
@@ -103,7 +132,7 @@ export const logout = <ThrowOnError extends boolean = false>(options?: Options<L
* Get current authenticated user * Get current authenticated user
*/ */
export const getMe = <ThrowOnError extends boolean = false>(options?: Options<GetMeData, ThrowOnError>) => { 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", url: "/api/v1/auth/me",
...options, ...options,
}); });
@@ -219,7 +248,7 @@ export const getContainersUsingVolume = <ThrowOnError extends boolean = false>(
* Mount a volume * Mount a volume
*/ */
export const mountVolume = <ThrowOnError extends boolean = false>(options: Options<MountVolumeData, ThrowOnError>) => { 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", url: "/api/v1/volumes/{name}/mount",
...options, ...options,
}); });
@@ -231,7 +260,7 @@ export const mountVolume = <ThrowOnError extends boolean = false>(options: Optio
export const unmountVolume = <ThrowOnError extends boolean = false>( export const unmountVolume = <ThrowOnError extends boolean = false>(
options: Options<UnmountVolumeData, ThrowOnError>, 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", url: "/api/v1/volumes/{name}/unmount",
...options, ...options,
}); });
@@ -248,3 +277,209 @@ export const healthCheckVolume = <ThrowOnError extends boolean = false>(
...options, ...options,
}); });
}; };
/**
* List files in a volume directory
*/
export const listFiles = <ThrowOnError extends boolean = false>(options: Options<ListFilesData, 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,
});
};

View File

@@ -23,8 +23,9 @@ export type RegisterResponses = {
*/ */
201: { 201: {
message: string; message: string;
user: { success: boolean;
id: string; user?: {
id: number;
username: string; username: string;
}; };
}; };
@@ -55,8 +56,9 @@ export type LoginResponses = {
*/ */
200: { 200: {
message: string; message: string;
user: { success: boolean;
id: string; user?: {
id: number;
username: string; username: string;
}; };
}; };
@@ -76,7 +78,7 @@ export type LogoutResponses = {
* Logout successful * Logout successful
*/ */
200: { 200: {
message: string; success: boolean;
}; };
}; };
@@ -89,21 +91,15 @@ export type GetMeData = {
url: "/api/v1/auth/me"; url: "/api/v1/auth/me";
}; };
export type GetMeErrors = {
/**
* Not authenticated
*/
401: unknown;
};
export type GetMeResponses = { export type GetMeResponses = {
/** /**
* Current user information * Current user information
*/ */
200: { 200: {
message: string; message: string;
user: { success: boolean;
id: string; user?: {
id: number;
username: string; username: string;
}; };
}; };
@@ -140,8 +136,7 @@ export type ListVolumesResponses = {
/** /**
* A list of volumes * A list of volumes
*/ */
200: { 200: Array<{
volumes: Array<{
autoRemount: boolean; autoRemount: boolean;
config: config:
| { | {
@@ -174,16 +169,15 @@ export type ListVolumesResponses = {
username?: string; username?: string;
}; };
createdAt: number; createdAt: number;
id: number;
lastError: string | null; lastError: string | null;
lastHealthCheck: number; lastHealthCheck: number;
name: string; name: string;
path: string; status: "error" | "mounted" | "unmounted";
status: "error" | "mounted" | "unknown" | "unmounted";
type: "directory" | "nfs" | "smb" | "webdav"; type: "directory" | "nfs" | "smb" | "webdav";
updatedAt: number; updatedAt: number;
}>; }>;
}; };
};
export type ListVolumesResponse = ListVolumesResponses[keyof ListVolumesResponses]; export type ListVolumesResponse = ListVolumesResponses[keyof ListVolumesResponses];
@@ -231,11 +225,45 @@ export type CreateVolumeResponses = {
* Volume created successfully * Volume created successfully
*/ */
201: { 201: {
message: string; autoRemount: boolean;
volume: { config:
name: string; | {
backend: "directory";
}
| {
backend: "nfs";
exportPath: string;
server: string;
version: "3" | "4" | "4.1";
port?: number;
}
| {
backend: "smb";
password: string;
server: string;
share: string;
username: string;
vers?: "1.0" | "2.0" | "2.1" | "3.0";
port?: number;
domain?: string;
}
| {
backend: "webdav";
path: string; path: string;
server: string;
port?: number;
password?: string;
ssl?: boolean;
username?: string;
}; };
createdAt: number;
id: number;
lastError: string | null;
lastHealthCheck: number;
name: string;
status: "error" | "mounted" | "unmounted";
type: "directory" | "nfs" | "smb" | "webdav";
updatedAt: number;
}; };
}; };
@@ -370,11 +398,11 @@ export type GetVolumeResponses = {
username?: string; username?: string;
}; };
createdAt: number; createdAt: number;
id: number;
lastError: string | null; lastError: string | null;
lastHealthCheck: number; lastHealthCheck: number;
name: string; name: string;
path: string; status: "error" | "mounted" | "unmounted";
status: "error" | "mounted" | "unknown" | "unmounted";
type: "directory" | "nfs" | "smb" | "webdav"; type: "directory" | "nfs" | "smb" | "webdav";
updatedAt: number; updatedAt: number;
}; };
@@ -436,8 +464,6 @@ export type UpdateVolumeResponses = {
* Volume updated successfully * Volume updated successfully
*/ */
200: { 200: {
message: string;
volume: {
autoRemount: boolean; autoRemount: boolean;
config: config:
| { | {
@@ -470,16 +496,15 @@ export type UpdateVolumeResponses = {
username?: string; username?: string;
}; };
createdAt: number; createdAt: number;
id: number;
lastError: string | null; lastError: string | null;
lastHealthCheck: number; lastHealthCheck: number;
name: string; name: string;
path: string; status: "error" | "mounted" | "unmounted";
status: "error" | "mounted" | "unknown" | "unmounted";
type: "directory" | "nfs" | "smb" | "webdav"; type: "directory" | "nfs" | "smb" | "webdav";
updatedAt: number; updatedAt: number;
}; };
}; };
};
export type UpdateVolumeResponse = UpdateVolumeResponses[keyof UpdateVolumeResponses]; export type UpdateVolumeResponse = UpdateVolumeResponses[keyof UpdateVolumeResponses];
@@ -503,15 +528,13 @@ export type GetContainersUsingVolumeResponses = {
/** /**
* List of containers using the volume * List of containers using the volume
*/ */
200: { 200: Array<{
containers: Array<{
id: string; id: string;
image: string; image: string;
name: string; name: string;
state: string; state: string;
}>; }>;
}; };
};
export type GetContainersUsingVolumeResponse = export type GetContainersUsingVolumeResponse =
GetContainersUsingVolumeResponses[keyof GetContainersUsingVolumeResponses]; GetContainersUsingVolumeResponses[keyof GetContainersUsingVolumeResponses];
@@ -525,13 +548,6 @@ export type MountVolumeData = {
url: "/api/v1/volumes/{name}/mount"; url: "/api/v1/volumes/{name}/mount";
}; };
export type MountVolumeErrors = {
/**
* Volume not found
*/
404: unknown;
};
export type MountVolumeResponses = { export type MountVolumeResponses = {
/** /**
* Volume mounted successfully * Volume mounted successfully
@@ -553,13 +569,6 @@ export type UnmountVolumeData = {
url: "/api/v1/volumes/{name}/unmount"; url: "/api/v1/volumes/{name}/unmount";
}; };
export type UnmountVolumeErrors = {
/**
* Volume not found
*/
404: unknown;
};
export type UnmountVolumeResponses = { export type UnmountVolumeResponses = {
/** /**
* Volume unmounted successfully * Volume unmounted successfully
@@ -600,6 +609,760 @@ export type HealthCheckVolumeResponses = {
export type HealthCheckVolumeResponse = HealthCheckVolumeResponses[keyof HealthCheckVolumeResponses]; export type HealthCheckVolumeResponse = HealthCheckVolumeResponses[keyof HealthCheckVolumeResponses];
export type ClientOptions = { export type ListFilesData = {
baseUrl: "http://localhost:4096" | (string & {}); body?: never;
path: {
name: string;
};
query?: {
/**
* Subdirectory path to list (relative to volume root)
*/
path?: string;
};
url: "/api/v1/volumes/{name}/files";
};
export type ListFilesResponses = {
/**
* List of files in the volume
*/
200: {
files: Array<{
name: string;
path: string;
type: "directory" | "file";
modifiedAt?: number;
size?: number;
}>;
path: string;
};
};
export type ListFilesResponse = ListFilesResponses[keyof ListFilesResponses];
export type ListRepositoriesData = {
body?: never;
path?: never;
query?: never;
url: "/api/v1/repositories";
};
export type ListRepositoriesResponses = {
/**
* List of repositories
*/
200: Array<{
compressionMode: "auto" | "better" | "fastest" | "max" | "off" | null;
config:
| {
accessKeyId: string;
backend: "s3";
bucket: string;
endpoint: string;
secretAccessKey: string;
}
| {
backend: "local";
name: string;
};
createdAt: number;
id: string;
lastChecked: number | null;
lastError: string | null;
name: string;
status: "error" | "healthy" | "unknown" | null;
type: "local" | "s3";
updatedAt: number;
}>;
};
export type ListRepositoriesResponse = ListRepositoriesResponses[keyof ListRepositoriesResponses];
export type CreateRepositoryData = {
body?: {
config:
| {
accessKeyId: string;
backend: "s3";
bucket: string;
endpoint: string;
secretAccessKey: string;
}
| {
backend: "local";
name: string;
};
name: string;
compressionMode?: "auto" | "better" | "fastest" | "max" | "off";
};
path?: never;
query?: never;
url: "/api/v1/repositories";
};
export type CreateRepositoryResponses = {
/**
* Repository created successfully
*/
201: {
message: string;
repository: {
id: string;
name: string;
};
};
};
export type CreateRepositoryResponse = CreateRepositoryResponses[keyof CreateRepositoryResponses];
export type DeleteRepositoryData = {
body?: never;
path: {
name: string;
};
query?: never;
url: "/api/v1/repositories/{name}";
};
export type DeleteRepositoryResponses = {
/**
* Repository deleted successfully
*/
200: {
message: string;
};
};
export type DeleteRepositoryResponse = DeleteRepositoryResponses[keyof DeleteRepositoryResponses];
export type GetRepositoryData = {
body?: never;
path: {
name: string;
};
query?: never;
url: "/api/v1/repositories/{name}";
};
export type GetRepositoryResponses = {
/**
* Repository details
*/
200: {
compressionMode: "auto" | "better" | "fastest" | "max" | "off" | null;
config:
| {
accessKeyId: string;
backend: "s3";
bucket: string;
endpoint: string;
secretAccessKey: string;
}
| {
backend: "local";
name: string;
};
createdAt: number;
id: string;
lastChecked: number | null;
lastError: string | null;
name: string;
status: "error" | "healthy" | "unknown" | null;
type: "local" | "s3";
updatedAt: number;
};
};
export type GetRepositoryResponse = GetRepositoryResponses[keyof GetRepositoryResponses];
export type ListSnapshotsData = {
body?: never;
path: {
name: string;
};
query?: {
backupId?: string;
};
url: "/api/v1/repositories/{name}/snapshots";
};
export type ListSnapshotsResponses = {
/**
* List of snapshots
*/
200: Array<{
duration: number;
paths: Array<string>;
short_id: string;
size: number;
time: number;
}>;
};
export type ListSnapshotsResponse = ListSnapshotsResponses[keyof ListSnapshotsResponses];
export type GetSnapshotDetailsData = {
body?: never;
path: {
name: string;
snapshotId: string;
};
query?: never;
url: "/api/v1/repositories/{name}/snapshots/{snapshotId}";
};
export type GetSnapshotDetailsResponses = {
/**
* Snapshot details
*/
200: {
duration: number;
paths: Array<string>;
short_id: string;
size: number;
time: number;
};
};
export type GetSnapshotDetailsResponse = GetSnapshotDetailsResponses[keyof GetSnapshotDetailsResponses];
export type ListSnapshotFilesData = {
body?: never;
path: {
name: string;
snapshotId: string;
};
query?: {
path?: string;
};
url: "/api/v1/repositories/{name}/snapshots/{snapshotId}/files";
};
export type ListSnapshotFilesResponses = {
/**
* List of files and directories in the snapshot
*/
200: {
files: Array<{
name: string;
path: string;
type: string;
atime?: string;
ctime?: string;
gid?: number;
mode?: number;
mtime?: string;
size?: number;
uid?: number;
}>;
snapshot: {
hostname: string;
id: string;
paths: Array<string>;
short_id: string;
time: string;
};
};
};
export type ListSnapshotFilesResponse = ListSnapshotFilesResponses[keyof ListSnapshotFilesResponses];
export type RestoreSnapshotData = {
body?: {
snapshotId: string;
delete?: boolean;
exclude?: Array<string>;
include?: Array<string>;
};
path: {
name: string;
};
query?: never;
url: "/api/v1/repositories/{name}/restore";
};
export type RestoreSnapshotResponses = {
/**
* Snapshot restored successfully
*/
200: {
filesRestored: number;
filesSkipped: number;
message: string;
success: boolean;
};
};
export type RestoreSnapshotResponse = RestoreSnapshotResponses[keyof RestoreSnapshotResponses];
export type ListBackupSchedulesData = {
body?: never;
path?: never;
query?: never;
url: "/api/v1/backups";
};
export type ListBackupSchedulesResponses = {
/**
* List of backup schedules
*/
200: Array<{
createdAt: number;
cronExpression: string;
enabled: boolean;
excludePatterns: Array<string> | null;
id: number;
includePatterns: Array<string> | null;
lastBackupAt: number | null;
lastBackupError: string | null;
lastBackupStatus: "error" | "success" | null;
nextBackupAt: number | null;
repository: {
compressionMode: "auto" | "better" | "fastest" | "max" | "off" | null;
config:
| {
accessKeyId: string;
backend: "s3";
bucket: string;
endpoint: string;
secretAccessKey: string;
}
| {
backend: "local";
name: string;
};
createdAt: number;
id: string;
lastChecked: number | null;
lastError: string | null;
name: string;
status: "error" | "healthy" | "unknown" | null;
type: "local" | "s3";
updatedAt: number;
};
repositoryId: string;
retentionPolicy: {
keepDaily?: number;
keepHourly?: number;
keepLast?: number;
keepMonthly?: number;
keepWeekly?: number;
keepWithinDuration?: string;
keepYearly?: number;
} | null;
updatedAt: number;
volume: {
autoRemount: boolean;
config:
| {
backend: "directory";
}
| {
backend: "nfs";
exportPath: string;
server: string;
version: "3" | "4" | "4.1";
port?: number;
}
| {
backend: "smb";
password: string;
server: string;
share: string;
username: string;
vers?: "1.0" | "2.0" | "2.1" | "3.0";
port?: number;
domain?: string;
}
| {
backend: "webdav";
path: string;
server: string;
port?: number;
password?: string;
ssl?: boolean;
username?: string;
};
createdAt: number;
id: number;
lastError: string | null;
lastHealthCheck: number;
name: string;
status: "error" | "mounted" | "unmounted";
type: "directory" | "nfs" | "smb" | "webdav";
updatedAt: number;
};
volumeId: number;
}>;
};
export type ListBackupSchedulesResponse = ListBackupSchedulesResponses[keyof ListBackupSchedulesResponses];
export type CreateBackupScheduleData = {
body?: {
cronExpression: string;
enabled: boolean;
repositoryId: string;
volumeId: number;
excludePatterns?: Array<string>;
includePatterns?: Array<string>;
retentionPolicy?: {
keepDaily?: number;
keepHourly?: number;
keepLast?: number;
keepMonthly?: number;
keepWeekly?: number;
keepWithinDuration?: string;
keepYearly?: number;
};
tags?: Array<string>;
};
path?: never;
query?: never;
url: "/api/v1/backups";
};
export type CreateBackupScheduleResponses = {
/**
* Backup schedule created successfully
*/
201: {
createdAt: number;
cronExpression: string;
enabled: boolean;
excludePatterns: Array<string> | null;
id: number;
includePatterns: Array<string> | null;
lastBackupAt: number | null;
lastBackupError: string | null;
lastBackupStatus: "error" | "success" | null;
nextBackupAt: number | null;
repositoryId: string;
retentionPolicy: {
keepDaily?: number;
keepHourly?: number;
keepLast?: number;
keepMonthly?: number;
keepWeekly?: number;
keepWithinDuration?: string;
keepYearly?: number;
} | null;
updatedAt: number;
volumeId: number;
};
};
export type CreateBackupScheduleResponse = CreateBackupScheduleResponses[keyof CreateBackupScheduleResponses];
export type DeleteBackupScheduleData = {
body?: never;
path: {
scheduleId: string;
};
query?: never;
url: "/api/v1/backups/{scheduleId}";
};
export type DeleteBackupScheduleResponses = {
/**
* Backup schedule deleted successfully
*/
200: {
success: boolean;
};
};
export type DeleteBackupScheduleResponse = DeleteBackupScheduleResponses[keyof DeleteBackupScheduleResponses];
export type GetBackupScheduleData = {
body?: never;
path: {
scheduleId: string;
};
query?: never;
url: "/api/v1/backups/{scheduleId}";
};
export type GetBackupScheduleResponses = {
/**
* Backup schedule details
*/
200: {
createdAt: number;
cronExpression: string;
enabled: boolean;
excludePatterns: Array<string> | null;
id: number;
includePatterns: Array<string> | null;
lastBackupAt: number | null;
lastBackupError: string | null;
lastBackupStatus: "error" | "success" | null;
nextBackupAt: number | null;
repository: {
compressionMode: "auto" | "better" | "fastest" | "max" | "off" | null;
config:
| {
accessKeyId: string;
backend: "s3";
bucket: string;
endpoint: string;
secretAccessKey: string;
}
| {
backend: "local";
name: string;
};
createdAt: number;
id: string;
lastChecked: number | null;
lastError: string | null;
name: string;
status: "error" | "healthy" | "unknown" | null;
type: "local" | "s3";
updatedAt: number;
};
repositoryId: string;
retentionPolicy: {
keepDaily?: number;
keepHourly?: number;
keepLast?: number;
keepMonthly?: number;
keepWeekly?: number;
keepWithinDuration?: string;
keepYearly?: number;
} | null;
updatedAt: number;
volume: {
autoRemount: boolean;
config:
| {
backend: "directory";
}
| {
backend: "nfs";
exportPath: string;
server: string;
version: "3" | "4" | "4.1";
port?: number;
}
| {
backend: "smb";
password: string;
server: string;
share: string;
username: string;
vers?: "1.0" | "2.0" | "2.1" | "3.0";
port?: number;
domain?: string;
}
| {
backend: "webdav";
path: string;
server: string;
port?: number;
password?: string;
ssl?: boolean;
username?: string;
};
createdAt: number;
id: number;
lastError: string | null;
lastHealthCheck: number;
name: string;
status: "error" | "mounted" | "unmounted";
type: "directory" | "nfs" | "smb" | "webdav";
updatedAt: number;
};
volumeId: number;
};
};
export type GetBackupScheduleResponse = GetBackupScheduleResponses[keyof GetBackupScheduleResponses];
export type UpdateBackupScheduleData = {
body?: {
cronExpression: string;
repositoryId: string;
enabled?: boolean;
excludePatterns?: Array<string>;
includePatterns?: Array<string>;
retentionPolicy?: {
keepDaily?: number;
keepHourly?: number;
keepLast?: number;
keepMonthly?: number;
keepWeekly?: number;
keepWithinDuration?: string;
keepYearly?: number;
};
tags?: Array<string>;
};
path: {
scheduleId: string;
};
query?: never;
url: "/api/v1/backups/{scheduleId}";
};
export type UpdateBackupScheduleResponses = {
/**
* Backup schedule updated successfully
*/
200: {
createdAt: number;
cronExpression: string;
enabled: boolean;
excludePatterns: Array<string> | null;
id: number;
includePatterns: Array<string> | null;
lastBackupAt: number | null;
lastBackupError: string | null;
lastBackupStatus: "error" | "success" | null;
nextBackupAt: number | null;
repositoryId: string;
retentionPolicy: {
keepDaily?: number;
keepHourly?: number;
keepLast?: number;
keepMonthly?: number;
keepWeekly?: number;
keepWithinDuration?: string;
keepYearly?: number;
} | null;
updatedAt: number;
volumeId: number;
};
};
export type UpdateBackupScheduleResponse = UpdateBackupScheduleResponses[keyof UpdateBackupScheduleResponses];
export type GetBackupScheduleForVolumeData = {
body?: never;
path: {
volumeId: string;
};
query?: never;
url: "/api/v1/backups/volume/{volumeId}";
};
export type GetBackupScheduleForVolumeResponses = {
/**
* Backup schedule details for the volume
*/
200: {
createdAt: number;
cronExpression: string;
enabled: boolean;
excludePatterns: Array<string> | null;
id: number;
includePatterns: Array<string> | null;
lastBackupAt: number | null;
lastBackupError: string | null;
lastBackupStatus: "error" | "success" | null;
nextBackupAt: number | null;
repository: {
compressionMode: "auto" | "better" | "fastest" | "max" | "off" | null;
config:
| {
accessKeyId: string;
backend: "s3";
bucket: string;
endpoint: string;
secretAccessKey: string;
}
| {
backend: "local";
name: string;
};
createdAt: number;
id: string;
lastChecked: number | null;
lastError: string | null;
name: string;
status: "error" | "healthy" | "unknown" | null;
type: "local" | "s3";
updatedAt: number;
};
repositoryId: string;
retentionPolicy: {
keepDaily?: number;
keepHourly?: number;
keepLast?: number;
keepMonthly?: number;
keepWeekly?: number;
keepWithinDuration?: string;
keepYearly?: number;
} | null;
updatedAt: number;
volume: {
autoRemount: boolean;
config:
| {
backend: "directory";
}
| {
backend: "nfs";
exportPath: string;
server: string;
version: "3" | "4" | "4.1";
port?: number;
}
| {
backend: "smb";
password: string;
server: string;
share: string;
username: string;
vers?: "1.0" | "2.0" | "2.1" | "3.0";
port?: number;
domain?: string;
}
| {
backend: "webdav";
path: string;
server: string;
port?: number;
password?: string;
ssl?: boolean;
username?: string;
};
createdAt: number;
id: number;
lastError: string | null;
lastHealthCheck: number;
name: string;
status: "error" | "mounted" | "unmounted";
type: "directory" | "nfs" | "smb" | "webdav";
updatedAt: number;
};
volumeId: number;
} | null;
};
export type GetBackupScheduleForVolumeResponse =
GetBackupScheduleForVolumeResponses[keyof GetBackupScheduleForVolumeResponses];
export type RunBackupNowData = {
body?: never;
path: {
scheduleId: string;
};
query?: never;
url: "/api/v1/backups/{scheduleId}/run";
};
export type RunBackupNowResponses = {
/**
* Backup started successfully
*/
200: {
success: boolean;
};
};
export type RunBackupNowResponse = RunBackupNowResponses[keyof RunBackupNowResponses];
export type ClientOptions = {
baseUrl: "http://192.168.2.42:4096" | (string & {});
}; };

View File

@@ -1,5 +1,6 @@
@import "tailwindcss"; @import "tailwindcss";
@import "tw-animate-css"; @import "tw-animate-css";
@import "dither-plugin";
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
@@ -11,16 +12,23 @@
html, html,
body { body {
@apply bg-white dark:bg-[#0D0D0D]; @apply bg-white dark:bg-[#131313];
overflow-x: hidden; overflow-x: hidden;
width: 100%; width: 100%;
position: relative; position: relative;
overscroll-behavior: none;
scrollbar-width: thin;
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
color-scheme: dark; color-scheme: dark;
} }
} }
.main-content {
scrollbar-width: thin;
scrollbar-gutter: stable;
}
@theme inline { @theme inline {
--radius-sm: calc(var(--radius) - 4px); --radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px); --radius-md: calc(var(--radius) - 2px);
@@ -30,6 +38,7 @@ body {
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--color-card: var(--card); --color-card: var(--card);
--color-card-foreground: var(--card-foreground); --color-card-foreground: var(--card-foreground);
--color-card-header: var(--card-header);
--color-popover: var(--popover); --color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground); --color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary); --color-primary: var(--primary);
@@ -57,6 +66,7 @@ body {
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border); --color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring); --color-sidebar-ring: var(--sidebar-ring);
--color-strong-accent: var(--strong-accent);
} }
:root { :root {
@@ -65,6 +75,7 @@ body {
--foreground: oklch(0.145 0 0); --foreground: oklch(0.145 0 0);
--card: oklch(1 0 0); --card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0); --card-foreground: oklch(0.145 0 0);
--card-header: oklch(0.922 0 0);
--popover: oklch(1 0 0); --popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0); --popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0); --primary: oklch(0.205 0 0);
@@ -92,12 +103,14 @@ body {
--sidebar-accent-foreground: oklch(0.205 0 0); --sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0); --sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0); --sidebar-ring: oklch(0.708 0 0);
--strong-accent: #ff543a;
} }
.dark { .dark {
--background: oklch(0.145 0 0); --background: #131313;
--foreground: oklch(0.985 0 0); --foreground: oklch(0.985 0 0);
--card: oklch(0.1448 0 0); --card: #131313;
--card-header: #1b1b1b;
/* --card: oklch(0.205 0 0); ORIGINAL */ /* --card: oklch(0.205 0 0); ORIGINAL */
--card-foreground: oklch(0.985 0 0); --card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0); --popover: oklch(0.205 0 0);
@@ -111,7 +124,7 @@ body {
--muted-foreground: oklch(0.708 0 0); --muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0); --accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0); --accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216); --destructive: #ff543a;
--border: oklch(1 0 0 / 10%); --border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%); --input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0); --ring: oklch(0.556 0 0);
@@ -128,6 +141,7 @@ body {
--sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%); --sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0); --sidebar-ring: oklch(0.556 0 0);
--strong-accent: #ff543a;
} }
@layer base { @layer base {

View File

@@ -8,13 +8,13 @@ import {
BreadcrumbSeparator, BreadcrumbSeparator,
} from "~/components/ui/breadcrumb"; } from "~/components/ui/breadcrumb";
import { useBreadcrumbs } from "~/lib/breadcrumbs"; import { useBreadcrumbs } from "~/lib/breadcrumbs";
import { cn } from "../lib/utils";
export function AppBreadcrumb() { export function AppBreadcrumb() {
const breadcrumbs = useBreadcrumbs(); const breadcrumbs = useBreadcrumbs();
return ( return (
<Breadcrumb className={cn("mb-2", { invisible: breadcrumbs.length <= 1 })}> <Breadcrumb>
<BreadcrumbLink asChild></BreadcrumbLink>
<BreadcrumbList> <BreadcrumbList>
{breadcrumbs.map((breadcrumb, index) => { {breadcrumbs.map((breadcrumb, index) => {
const isLast = index === breadcrumbs.length - 1; const isLast = index === breadcrumbs.length - 1;

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 = { type ByteSizeProps = {
bytes: number; bytes: number;
@@ -54,7 +54,7 @@ export function formatBytes(
idx = Math.max(0, Math.min(idx, units.length - 1)); 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 = (() => { const maxFrac = (() => {
if (!smartRounding) return maximumFractionDigits; 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

@@ -6,15 +6,7 @@ import { createVolumeMutation } from "~/api-client/@tanstack/react-query.gen";
import { parseError } from "~/lib/errors"; import { parseError } from "~/lib/errors";
import { CreateVolumeForm } from "./create-volume-form"; import { CreateVolumeForm } from "./create-volume-form";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
import { import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "./ui/dialog";
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "./ui/dialog";
import { ScrollArea } from "./ui/scroll-area"; import { ScrollArea } from "./ui/scroll-area";
type Props = { type Props = {
@@ -41,13 +33,13 @@ export const CreateVolumeDialog = ({ open, setOpen }: Props) => {
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button className="bg-blue-900 hover:bg-blue-800"> <Button>
<Plus size={16} className="mr-2" /> <Plus size={16} className="mr-2" />
Create volume Create volume
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent>
<ScrollArea className="h-[500px]"> <ScrollArea className="h-[500px] p-4">
<DialogHeader> <DialogHeader>
<DialogTitle>Create volume</DialogTitle> <DialogTitle>Create volume</DialogTitle>
</DialogHeader> </DialogHeader>

View File

@@ -7,6 +7,7 @@ import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { testConnectionMutation } from "~/api-client/@tanstack/react-query.gen"; import { testConnectionMutation } from "~/api-client/@tanstack/react-query.gen";
import { cn, slugify } from "~/lib/utils"; import { cn, slugify } from "~/lib/utils";
import { deepClean } from "~/utils/object";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "./ui/form"; import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "./ui/form";
import { Input } from "./ui/input"; import { Input } from "./ui/input";
@@ -15,6 +16,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from ".
export const formSchema = type({ export const formSchema = type({
name: "2<=string<=32", name: "2<=string<=32",
}).and(volumeConfigSchema); }).and(volumeConfigSchema);
const cleanSchema = type.pipe((d) => formSchema(deepClean(d)));
export type FormValues = typeof formSchema.inferIn; export type FormValues = typeof formSchema.inferIn;
@@ -36,7 +38,7 @@ const defaultValuesForType = {
export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, formId, loading, className }: Props) => { export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, formId, loading, className }: Props) => {
const form = useForm<FormValues>({ const form = useForm<FormValues>({
resolver: arktypeResolver(formSchema), resolver: arktypeResolver(cleanSchema as unknown as typeof formSchema),
defaultValues: initialValues, defaultValues: initialValues,
resetOptions: { resetOptions: {
keepDefaultValues: true, keepDefaultValues: true,
@@ -53,22 +55,21 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
form.reset({ name: watchedName, ...defaultValuesForType[watchedBackend as keyof typeof defaultValuesForType] }); form.reset({ name: watchedName, ...defaultValuesForType[watchedBackend as keyof typeof defaultValuesForType] });
}, [watchedBackend, watchedName, form.reset]); }, [watchedBackend, watchedName, form.reset]);
const [testMessage, setTestMessage] = useState<string>(""); const [testMessage, setTestMessage] = useState<{ success: boolean; message: string } | null>(null);
const testBackendConnection = useMutation({ const testBackendConnection = useMutation({
...testConnectionMutation(), ...testConnectionMutation(),
onMutate: () => { onMutate: () => {
setTestMessage(""); setTestMessage(null);
}, },
onError: () => { onError: (error) => {
setTestMessage("Failed to test connection. Please try again."); setTestMessage({
success: false,
message: error?.message || "Failed to test connection. Please try again.",
});
}, },
onSuccess: (data) => { onSuccess: (data) => {
if (data?.success) { setTestMessage(data);
setTestMessage(data.message);
} else {
setTestMessage(data?.message || "Connection test failed");
}
}, },
}); });
@@ -312,7 +313,7 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
<FormItem> <FormItem>
<FormLabel>Server</FormLabel> <FormLabel>Server</FormLabel>
<FormControl> <FormControl>
<Input placeholder="192.168.1.100" value={field.value ?? ""} onChange={field.onChange} /> <Input placeholder="192.168.1.100" value={field.value} onChange={field.onChange} />
</FormControl> </FormControl>
<FormDescription>SMB server IP address or hostname.</FormDescription> <FormDescription>SMB server IP address or hostname.</FormDescription>
<FormMessage /> <FormMessage />
@@ -326,7 +327,7 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
<FormItem> <FormItem>
<FormLabel>Share</FormLabel> <FormLabel>Share</FormLabel>
<FormControl> <FormControl>
<Input placeholder="myshare" value={field.value ?? ""} onChange={field.onChange} /> <Input placeholder="myshare" value={field.value} onChange={field.onChange} />
</FormControl> </FormControl>
<FormDescription>SMB share name on the server.</FormDescription> <FormDescription>SMB share name on the server.</FormDescription>
<FormMessage /> <FormMessage />
@@ -340,7 +341,7 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
<FormItem> <FormItem>
<FormLabel>Username</FormLabel> <FormLabel>Username</FormLabel>
<FormControl> <FormControl>
<Input placeholder="admin" value={field.value ?? ""} onChange={field.onChange} /> <Input placeholder="admin" value={field.value} onChange={field.onChange} />
</FormControl> </FormControl>
<FormDescription>Username for SMB authentication.</FormDescription> <FormDescription>Username for SMB authentication.</FormDescription>
<FormMessage /> <FormMessage />
@@ -354,7 +355,7 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
<FormItem> <FormItem>
<FormLabel>Password</FormLabel> <FormLabel>Password</FormLabel>
<FormControl> <FormControl>
<Input type="password" placeholder="••••••••" value={field.value ?? ""} onChange={field.onChange} /> <Input type="password" placeholder="••••••••" value={field.value} onChange={field.onChange} />
</FormControl> </FormControl>
<FormDescription>Password for SMB authentication.</FormDescription> <FormDescription>Password for SMB authentication.</FormDescription>
<FormMessage /> <FormMessage />
@@ -424,7 +425,6 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
</> </>
)} )}
{watchedBackend === "smb" && (
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button <Button
@@ -435,101 +435,34 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
className="flex-1" className="flex-1"
> >
{testBackendConnection.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} {testBackendConnection.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{testBackendConnection.isSuccess && <CheckCircle className="mr-2 h-4 w-4 text-green-500" />} {!testBackendConnection.isPending && testMessage?.success && (
{testBackendConnection.isError && <XCircle className="mr-2 h-4 w-4 text-red-500" />} <CheckCircle className="mr-2 h-4 w-4 text-green-500" />
{testBackendConnection.isIdle && "Test Connection"} )}
{testBackendConnection.isPending && "Testing..."} {!testBackendConnection.isPending && testMessage && !testMessage.success && (
{testBackendConnection.isSuccess && "Connection Successful"} <XCircle className="mr-2 h-4 w-4 text-red-500" />
{testBackendConnection.isError && "Test Failed"} )}
{testBackendConnection.isPending
? "Testing..."
: testMessage
? testMessage.success
? "Connection Successful"
: "Test Failed"
: "Test Connection"}
</Button> </Button>
</div> </div>
{testMessage && ( {testMessage && (
<div <div
className={`text-sm p-2 rounded-md ${ className={cn("text-xs p-2 rounded-md text-wrap wrap-anywhere", {
testBackendConnection.isSuccess "bg-green-50 text-green-700 border border-green-200": testMessage.success,
? "bg-green-50 text-green-700 border border-green-200" "bg-red-50 text-red-700 border border-red-200": !testMessage.success,
: testBackendConnection.isError })}
? "bg-red-50 text-red-700 border border-red-200"
: "bg-gray-50 text-gray-700 border border-gray-200"
}`}
> >
{testMessage} {testMessage.message}
</div> </div>
)} )}
</div> </div>
)}
{watchedBackend === "nfs" && (
<div className="space-y-3">
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
onClick={handleTestConnection}
disabled={testBackendConnection.isPending}
className="flex-1"
>
{testBackendConnection.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{testBackendConnection.isSuccess && <CheckCircle className="mr-2 h-4 w-4 text-green-500" />}
{testBackendConnection.isError && <XCircle className="mr-2 h-4 w-4 text-red-500" />}
{testBackendConnection.isIdle && "Test Connection"}
{testBackendConnection.isPending && "Testing..."}
{testBackendConnection.isSuccess && "Connection Successful"}
{testBackendConnection.isError && "Test Failed"}
</Button>
</div>
{testMessage && (
<div
className={`text-sm p-2 rounded-md ${
testBackendConnection.isSuccess
? "bg-green-50 text-green-700 border border-green-200"
: testBackendConnection.isError
? "bg-red-50 text-red-700 border border-red-200"
: "bg-gray-50 text-gray-700 border border-gray-200"
}`}
>
{testMessage}
</div>
)}
</div>
)}
{watchedBackend === "webdav" && (
<div className="space-y-3">
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
onClick={handleTestConnection}
disabled={testBackendConnection.isPending}
className="flex-1"
>
{testBackendConnection.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{testBackendConnection.isSuccess && <CheckCircle className="mr-2 h-4 w-4 text-green-500" />}
{testBackendConnection.isError && <XCircle className="mr-2 h-4 w-4 text-red-500" />}
{testBackendConnection.isIdle && "Test Connection"}
{testBackendConnection.isPending && "Testing..."}
{testBackendConnection.isSuccess && "Connection Successful"}
{testBackendConnection.isError && "Test Failed"}
</Button>
</div>
{testMessage && (
<div
className={`text-sm p-2 rounded-md ${
testBackendConnection.isSuccess
? "bg-green-50 text-green-700 border border-green-200"
: testBackendConnection.isError
? "bg-red-50 text-red-700 border border-red-200"
: "bg-gray-50 text-gray-700 border border-gray-200"
}`}
>
{testMessage}
</div>
)}
</div>
)}
{mode === "update" && ( {mode === "update" && (
<Button type="submit" className="w-full mt-4" loading={loading}> <Button type="submit" className="w-full" loading={loading}>
Save Changes Save Changes
</Button> </Button>
)} )}

View File

@@ -0,0 +1,32 @@
import { Card } from "./ui/card";
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 (
<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>
<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>
</div>
<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>
</Card>
);
}

View File

@@ -0,0 +1,563 @@
/**
* FileTree Component
*
* Adapted from bolt.new by StackBlitz
* Copyright (c) 2024 StackBlitz, Inc.
* Licensed under the MIT License
*
* Original source: https://github.com/stackblitz/bolt.new
*/
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;
export interface FileEntry {
name: string;
path: string;
type: string;
size?: number;
modifiedAt?: number;
}
interface Props {
files?: FileEntry[];
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) => {
const {
files = [],
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, foldersOnly);
}, [files, foldersOnly]);
const [collapsedFolders, setCollapsedFolders] = useState<Set<string>>(new Set());
const filteredFileList = useMemo(() => {
const list = [];
let lastDepth = Number.MAX_SAFE_INTEGER;
for (const fileOrFolder of fileList) {
const depth = fileOrFolder.depth;
// if the depth is equal we reached the end of the collapsed group
if (lastDepth === depth) {
lastDepth = Number.MAX_SAFE_INTEGER;
}
// ignore collapsed folders
if (collapsedFolders.has(fileOrFolder.fullPath)) {
lastDepth = Math.min(lastDepth, depth);
}
// ignore files and folders below the last collapsed folder
if (lastDepth < depth) {
continue;
}
list.push(fileOrFolder);
}
return list;
}, [fileList, collapsedFolders]);
const toggleCollapseState = useCallback(
(fullPath: string) => {
setCollapsedFolders((prevSet) => {
const newSet = new Set(prevSet);
if (newSet.has(fullPath)) {
newSet.delete(fullPath);
onFolderExpand?.(fullPath);
} else {
newSet.add(fullPath);
}
return newSet;
});
},
[onFolderExpand],
);
// Add new folders to collapsed set when file list changes
useEffect(() => {
setCollapsedFolders((prevSet) => {
const newSet = new Set(prevSet);
for (const item of fileList) {
if (item.kind === "folder" && !newSet.has(item.fullPath) && !expandedFolders.has(item.fullPath)) {
newSet.add(item.fullPath);
}
}
return newSet;
});
}, [fileList, expandedFolders]);
const handleFileSelect = useCallback(
(filePath: string) => {
onFileSelect?.(filePath);
},
[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) => {
switch (fileOrFolder.kind) {
case "file": {
return (
<File
key={fileOrFolder.id}
selected={selectedFile === fileOrFolder.fullPath}
file={fileOrFolder}
onFileSelect={handleFileSelect}
withCheckbox={withCheckboxes}
checked={isPathSelected(fileOrFolder.fullPath)}
onCheckboxChange={handleSelectionChange}
/>
);
}
case "folder": {
return (
<Folder
key={fileOrFolder.id}
folder={fileOrFolder}
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}
/>
);
}
default: {
return undefined;
}
}
})}
</div>
);
});
interface FolderProps {
folder: FolderNode;
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,
onHover,
withCheckbox,
checked,
partiallyChecked,
onCheckboxChange,
}: FolderProps) => {
const { depth, name, fullPath } = folder;
const FolderIconComponent = collapsed ? FolderIcon : FolderOpen;
const handleChevronClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
onToggle(fullPath);
},
[onToggle, fullPath],
);
const handleMouseEnter = useCallback(() => {
if (collapsed) {
onHover?.(fullPath);
}
}, [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, 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 cursor-pointer", {
"hover:bg-accent/50 text-foreground": !selected,
"bg-accent text-accent-foreground": selected,
})}
depth={depth}
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>
);
});
interface ButtonProps {
depth: number;
icon: ReactNode;
children: ReactNode;
className?: string;
onClick?: () => void;
onMouseEnter?: () => void;
}
const NodeButton = memo(({ depth, icon, onClick, onMouseEnter, className, children }: ButtonProps) => {
const paddingLeft = useMemo(() => `${8 + depth * NODE_PADDING_LEFT}px`, [depth]);
return (
<button
type="button"
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>
</button>
);
});
type Node = FileNode | FolderNode;
interface BaseNode {
id: number;
depth: number;
name: string;
fullPath: string;
}
interface FileNode extends BaseNode {
kind: "file";
}
interface FolderNode extends BaseNode {
kind: "folder";
}
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];
if (!fileMap.has(file.path)) {
fileMap.set(file.path, {
kind: file.type === "file" ? "file" : "folder",
id: fileMap.size,
name,
fullPath: file.path,
depth,
});
}
}
// Convert map to array and sort
return sortFileList(Array.from(fileMap.values()));
}
function sortFileList(nodeList: Node[]): Node[] {
const nodeMap = new Map<string, Node>();
const childrenMap = new Map<string, Node[]>();
// Pre-sort nodes by name and type
nodeList.sort((a, b) => compareNodes(a, b));
for (const node of nodeList) {
nodeMap.set(node.fullPath, node);
const parentPath = node.fullPath.slice(0, node.fullPath.lastIndexOf("/")) || "/";
if (parentPath !== "/") {
if (!childrenMap.has(parentPath)) {
childrenMap.set(parentPath, []);
}
childrenMap.get(parentPath)?.push(node);
}
}
const sortedList: Node[] = [];
const depthFirstTraversal = (path: string): void => {
const node = nodeMap.get(path);
if (node) {
sortedList.push(node);
}
const children = childrenMap.get(path);
if (children) {
for (const child of children) {
if (child.kind === "folder") {
depthFirstTraversal(child.fullPath);
} else {
sortedList.push(child);
}
}
}
};
// Start with root level items
const rootItems = nodeList.filter((node) => {
const parentPath = node.fullPath.slice(0, node.fullPath.lastIndexOf("/")) || "/";
return parentPath === "/";
});
for (const item of rootItems) {
depthFirstTraversal(item.fullPath);
}
return sortedList;
}
function compareNodes(a: Node, b: Node): number {
if (a.kind !== b.kind) {
return a.kind === "folder" ? -1 : 1;
}
return a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" });
}

View File

@@ -0,0 +1,24 @@
import type { ReactNode } from "react";
import { cn } from "~/lib/utils";
interface GridBackgroundProps {
children: ReactNode;
className?: string;
containerClassName?: string;
}
export function GridBackground({ children, className, containerClassName }: GridBackgroundProps) {
return (
<div
className={cn(
"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={cn("relative container m-auto", className)}>{children}</div>
</div>
);
}

View File

@@ -1,13 +1,16 @@
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { LifeBuoy } from "lucide-react";
import { Outlet, useNavigate } from "react-router"; import { Outlet, useNavigate } from "react-router";
import { toast } from "sonner"; import { toast } from "sonner";
import { cn } from "~/lib/utils";
import { AppBreadcrumb } from "./app-breadcrumb";
import { Button } from "./ui/button";
import { logoutMutation } from "~/api-client/@tanstack/react-query.gen"; import { logoutMutation } from "~/api-client/@tanstack/react-query.gen";
import type { Route } from "./+types/layout";
import { appContext } from "~/context"; import { appContext } from "~/context";
import { authMiddleware } from "~/middleware/auth"; import { authMiddleware } from "~/middleware/auth";
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]; export const clientMiddleware = [authMiddleware];
@@ -26,34 +29,54 @@ export default function Layout({ loaderData }: Route.ComponentProps) {
}, },
onError: (error) => { onError: (error) => {
console.error(error); console.error(error);
toast.error("Logout failed"); toast.error("Logout failed", { description: error.message });
}, },
}); });
return ( return (
<div <SidebarProvider defaultOpen={true}>
className={cn( <AppSidebar />
"relative min-h-dvh w-full overflow-x-hidden", <div className="w-full relative flex flex-col h-screen overflow-hidden">
"[background-size:20px_20px] sm:[background-size:40px_40px]", <header className="z-50 bg-card-header border-b border-border/50 flex-shrink-0">
"[background-image:linear-gradient(to_right,#e4e4e7_1px,transparent_1px),linear-gradient(to_bottom,#e4e4e7_1px,transparent_1px)]", <div className="flex items-center justify-between py-3 sm:py-4 px-2 sm:px-8 mx-auto container">
"dark:[background-image:linear-gradient(to_right,#262626_1px,transparent_1px),linear-gradient(to_bottom,#262626_1px,transparent_1px)]", <div className="flex items-center gap-4">
)} <SidebarTrigger />
>
<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-black"></div>
<main className="relative flex flex-col pt-4 sm:pt-8 px-2 sm:px-4 pb-4 container mx-auto">
<div className="flex items-center justify-between mb-4">
<AppBreadcrumb /> <AppBreadcrumb />
</div>
{loaderData.user && ( {loaderData.user && (
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<span className="text-sm text-muted-foreground">Welcome, {loaderData.user?.username}</span> <span className="text-sm text-muted-foreground hidden md:inline-flex">
<Button variant="outline" size="sm" onClick={() => logout.mutate({})} loading={logout.isPending}> Welcome,&nbsp;
<span className="text-strong-accent">{loaderData.user?.username}</span>
</span>
<Button variant="default" size="sm" onClick={() => logout.mutate({})} loading={logout.isPending}>
Logout Logout
</Button> </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>
)} )}
</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 /> <Outlet />
</main> </main>
</GridBackground>
</div> </div>
</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

@@ -6,23 +6,23 @@ import type * as React from "react";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
const buttonVariants = cva( const buttonVariants = cva(
"inline-flex cursor-pointer uppercase border items-center justify-center dark:border-white dark:bg-secondary dark:text-white gap-2 whitespace-nowrap text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-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", "inline-flex cursor-pointer uppercase rounded-sm items-center justify-center gap-2 whitespace-nowrap text-xs font-semibold tracking-wide transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-ring border-0",
{ {
variants: { variants: {
variant: { variant: {
default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", default: "bg-transparent text-white hover:bg-[#3A3A3A]/80 border dark:text-white dark:hover:bg-[#3A3A3A]/80",
primary: "bg-strong-accent text-white hover:bg-strong-accent/90 focus-visible:ring-strong-accent/50",
destructive: destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", "border border-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/50 text-destructive hover:text-white",
outline: outline: "border border-border bg-background hover:bg-accent hover:text-accent-foreground",
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", secondary: "bg-transparent text-white hover:bg-[#3A3A3A]/80 border dark:text-white dark:hover:bg-[#3A3A3A]/80",
secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", ghost: "hover:bg-accent hover:text-accent-foreground",
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline", link: "text-primary underline-offset-4 hover:underline",
}, },
size: { size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3", default: "h-9 px-5 py-2 has-[>svg]:px-4",
sm: "rounded-xs h-8 gap-1.5 px-3 has-[>svg]:px-2.5", sm: "h-8 px-3 py-1.5 has-[>svg]:px-2.5",
lg: "h-10 px-6 has-[>svg]:px-4", lg: "h-10 px-6 py-2.5 has-[>svg]:px-5",
icon: "size-9", icon: "size-9",
}, },
}, },
@@ -38,6 +38,7 @@ function Button({
variant, variant,
size, size,
asChild = false, asChild = false,
loading,
...props ...props
}: React.ComponentProps<"button"> & }: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & { VariantProps<typeof buttonVariants> & {
@@ -47,13 +48,13 @@ function Button({
return ( return (
<Comp <Comp
disabled={props.loading} disabled={loading}
data-slot="button" data-slot="button"
className={cn(buttonVariants({ variant, size, className }), "transition-all")} className={cn(buttonVariants({ variant, size, className }), "transition-all")}
{...props} {...props}
> >
<Loader2 className={cn("h-4 w-4 animate-spin absolute", { invisible: !props.loading })} /> <Loader2 className={cn("h-4 w-4 animate-spin absolute", { invisible: !loading })} />
<div className={cn("flex items-center justify-center", { invisible: props.loading })}>{props.children}</div> <div className={cn("flex items-center justify-center", { invisible: loading })}>{props.children}</div>
</Comp> </Comp>
); );
} }

View File

@@ -1,17 +1,26 @@
import * as React from "react"; import type * as React from "react";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
function Card({ className, ...props }: React.ComponentProps<"div">) { function Card({ className, children, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="card" data-slot="card"
className={cn( className={cn("bg-card text-card-foreground relative flex flex-col gap-6 border-2 py-6 shadow-sm", className)}
"bg-card text-card-foreground flex flex-col gap-6 border-2 py-6 shadow-sm",
className,
)}
{...props} {...props}
/> >
<span aria-hidden="true" className="pointer-events-none absolute inset-0 z-10 select-none">
<span className="absolute left-[-2px] top-[-2px] h-0.5 w-4 bg-white/80" />
<span className="absolute left-[-2px] top-[-2px] h-4 w-0.5 bg-white/80" />
<span className="absolute right-[-2px] top-[-2px] h-0.5 w-4 bg-white/80" />
<span className="absolute right-[-2px] top-[-2px] h-4 w-0.5 bg-white/80" />
<span className="absolute left-[-2px] bottom-[-2px] h-0.5 w-4 bg-white/80" />
<span className="absolute left-[-2px] bottom-[-2px] h-4 w-0.5 bg-white/80" />
<span className="absolute right-[-2px] bottom-[-2px] h-0.5 w-4 bg-white/80" />
<span className="absolute right-[-2px] bottom-[-2px] h-4 w-0.5 bg-white/80" />
</span>
{children}
</div>
); );
} }
@@ -29,64 +38,31 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
} }
function CardTitle({ className, ...props }: React.ComponentProps<"div">) { function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return ( return <div data-slot="card-title" className={cn("leading-none font-semibold", className)} {...props} />;
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
);
} }
function CardDescription({ className, ...props }: React.ComponentProps<"div">) { function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return ( return <div data-slot="card-description" className={cn("text-muted-foreground text-sm", className)} {...props} />;
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
} }
function CardAction({ className, ...props }: React.ComponentProps<"div">) { function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="card-action" data-slot="card-action"
className={cn( className={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className,
)}
{...props} {...props}
/> />
); );
} }
function CardContent({ className, ...props }: React.ComponentProps<"div">) { function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return ( return <div data-slot="card-content" className={cn("px-6", className)} {...props} />;
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
);
} }
function CardFooter({ className, ...props }: React.ComponentProps<"div">) { function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div data-slot="card-footer" className={cn("flex items-center px-6 [.border-t]:pt-6", className)} {...props} />
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
); );
} }
export { export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent };
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
};

View File

@@ -1,35 +1,34 @@
import * as React from "react" // @ts-nocheck
import * as RechartsPrimitive from "recharts" // 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 } // Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const const THEMES = { light: "", dark: ".dark" } as const;
export type ChartConfig = { export type ChartConfig = {
[k in string]: { [k in string]: {
label?: React.ReactNode label?: React.ReactNode;
icon?: React.ComponentType icon?: React.ComponentType;
} & ( } & ({ color?: string; theme?: never } | { color?: never; theme: Record<keyof typeof THEMES, string> });
| { color?: string; theme?: never } };
| { color?: never; theme: Record<keyof typeof THEMES, string> }
)
}
type ChartContextProps = { type ChartContextProps = {
config: ChartConfig config: ChartConfig;
} };
const ChartContext = React.createContext<ChartContextProps | null>(null) const ChartContext = React.createContext<ChartContextProps | null>(null);
function useChart() { function useChart() {
const context = React.useContext(ChartContext) const context = React.useContext(ChartContext);
if (!context) { if (!context) {
throw new Error("useChart must be used within a <ChartContainer />") throw new Error("useChart must be used within a <ChartContainer />");
} }
return context return context;
} }
function ChartContainer({ function ChartContainer({
@@ -39,13 +38,11 @@ function ChartContainer({
config, config,
...props ...props
}: React.ComponentProps<"div"> & { }: React.ComponentProps<"div"> & {
config: ChartConfig config: ChartConfig;
children: React.ComponentProps< children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>["children"];
typeof RechartsPrimitive.ResponsiveContainer
>["children"]
}) { }) {
const uniqueId = React.useId() const uniqueId = React.useId();
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}` const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
return ( return (
<ChartContext.Provider value={{ config }}> <ChartContext.Provider value={{ config }}>
@@ -54,26 +51,22 @@ function ChartContainer({
data-chart={chartId} data-chart={chartId}
className={cn( 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", "[&_.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 className,
)} )}
{...props} {...props}
> >
<ChartStyle id={chartId} config={config} /> <ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer> <RechartsPrimitive.ResponsiveContainer>{children}</RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div> </div>
</ChartContext.Provider> </ChartContext.Provider>
) );
} }
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter( const colorConfig = Object.entries(config).filter(([, config]) => config.theme || config.color);
([, config]) => config.theme || config.color
)
if (!colorConfig.length) { if (!colorConfig.length) {
return null return null;
} }
return ( return (
@@ -85,22 +78,20 @@ const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
${prefix} [data-chart=${id}] { ${prefix} [data-chart=${id}] {
${colorConfig ${colorConfig
.map(([key, itemConfig]) => { .map(([key, itemConfig]) => {
const color = const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.color;
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || return color ? ` --color-${key}: ${color};` : null;
itemConfig.color
return color ? ` --color-${key}: ${color};` : null
}) })
.join("\n")} .join("\n")}
} }
` `,
) )
.join("\n"), .join("\n"),
}} }}
/> />
) );
} };
const ChartTooltip = RechartsPrimitive.Tooltip const ChartTooltip = RechartsPrimitive.Tooltip;
function ChartTooltipContent({ function ChartTooltipContent({
active, active,
@@ -118,61 +109,47 @@ function ChartTooltipContent({
labelKey, labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> & }: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & { React.ComponentProps<"div"> & {
hideLabel?: boolean hideLabel?: boolean;
hideIndicator?: boolean hideIndicator?: boolean;
indicator?: "line" | "dot" | "dashed" indicator?: "line" | "dot" | "dashed";
nameKey?: string nameKey?: string;
labelKey?: string labelKey?: string;
}) { }) {
const { config } = useChart() const { config } = useChart();
const tooltipLabel = React.useMemo(() => { const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) { if (hideLabel || !payload?.length) {
return null return null;
} }
const [item] = payload const [item] = payload;
const key = `${labelKey || item?.dataKey || item?.name || "value"}` const key = `${labelKey || item?.dataKey || item?.name || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key) const itemConfig = getPayloadConfigFromPayload(config, item, key);
const value = const value =
!labelKey && typeof label === "string" !labelKey && typeof label === "string" ? config[label as keyof typeof config]?.label || label : itemConfig?.label;
? config[label as keyof typeof config]?.label || label
: itemConfig?.label
if (labelFormatter) { if (labelFormatter) {
return ( return <div className={cn("font-medium", labelClassName)}>{labelFormatter(value, payload)}</div>;
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
)
} }
if (!value) { if (!value) {
return null return null;
} }
return <div className={cn("font-medium", labelClassName)}>{value}</div> return <div className={cn("font-medium", labelClassName)}>{value}</div>;
}, [ }, [label, labelFormatter, payload, hideLabel, labelClassName, config, labelKey]);
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
])
if (!active || !payload?.length) { if (!active || !payload?.length) {
return null return null;
} }
const nestLabel = payload.length === 1 && indicator !== "dot" const nestLabel = payload.length === 1 && indicator !== "dot";
return ( return (
<div <div
className={cn( 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", "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 className,
)} )}
> >
{!nestLabel ? tooltipLabel : null} {!nestLabel ? tooltipLabel : null}
@@ -180,16 +157,16 @@ function ChartTooltipContent({
{payload {payload
.filter((item) => item.type !== "none") .filter((item) => item.type !== "none")
.map((item, index) => { .map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}` const key = `${nameKey || item.name || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key) const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor = color || item.payload.fill || item.color const indicatorColor = color || item.payload.fill || item.color;
return ( return (
<div <div
key={item.dataKey} key={item.dataKey}
className={cn( className={cn(
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5", "[&>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" indicator === "dot" && "items-center",
)} )}
> >
{formatter && item?.value !== undefined && item.name ? ( {formatter && item?.value !== undefined && item.name ? (
@@ -201,16 +178,12 @@ function ChartTooltipContent({
) : ( ) : (
!hideIndicator && ( !hideIndicator && (
<div <div
className={cn( className={cn("shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)", {
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{
"h-2.5 w-2.5": indicator === "dot", "h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line", "w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent": "w-0 border-[1.5px] border-dashed bg-transparent": indicator === "dashed",
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed", "my-0.5": nestLabel && indicator === "dashed",
} })}
)}
style={ style={
{ {
"--color-bg": indicatorColor, "--color-bg": indicatorColor,
@@ -223,14 +196,12 @@ function ChartTooltipContent({
<div <div
className={cn( className={cn(
"flex flex-1 justify-between leading-none", "flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center" nestLabel ? "items-end" : "items-center",
)} )}
> >
<div className="grid gap-1.5"> <div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null} {nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground"> <span className="text-muted-foreground">{itemConfig?.label || item.name}</span>
{itemConfig?.label || item.name}
</span>
</div> </div>
{item.value && ( {item.value && (
<span className="text-foreground font-mono font-medium tabular-nums"> <span className="text-foreground font-mono font-medium tabular-nums">
@@ -241,14 +212,14 @@ function ChartTooltipContent({
</> </>
)} )}
</div> </div>
) );
})} })}
</div> </div>
</div> </div>
) );
} }
const ChartLegend = RechartsPrimitive.Legend const ChartLegend = RechartsPrimitive.Legend;
function ChartLegendContent({ function ChartLegendContent({
className, className,
@@ -258,35 +229,27 @@ function ChartLegendContent({
nameKey, nameKey,
}: React.ComponentProps<"div"> & }: React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & { Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean hideIcon?: boolean;
nameKey?: string nameKey?: string;
}) { }) {
const { config } = useChart() const { config } = useChart();
if (!payload?.length) { if (!payload?.length) {
return null return null;
} }
return ( return (
<div <div className={cn("flex items-center justify-center gap-4", verticalAlign === "top" ? "pb-3" : "pt-3", className)}>
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload {payload
.filter((item) => item.type !== "none") .filter((item) => item.type !== "none")
.map((item) => { .map((item) => {
const key = `${nameKey || item.dataKey || "value"}` const key = `${nameKey || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key) const itemConfig = getPayloadConfigFromPayload(config, item, key);
return ( return (
<div <div
key={item.value} key={item.value}
className={cn( className={cn("[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3")}
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3"
)}
> >
{itemConfig?.icon && !hideIcon ? ( {itemConfig?.icon && !hideIcon ? (
<itemConfig.icon /> <itemConfig.icon />
@@ -300,56 +263,36 @@ function ChartLegendContent({
)} )}
{itemConfig?.label} {itemConfig?.label}
</div> </div>
) );
})} })}
</div> </div>
) );
} }
// Helper to extract item config from a payload. // Helper to extract item config from a payload.
function getPayloadConfigFromPayload( function getPayloadConfigFromPayload(config: ChartConfig, payload: unknown, key: string) {
config: ChartConfig,
payload: unknown,
key: string
) {
if (typeof payload !== "object" || payload === null) { if (typeof payload !== "object" || payload === null) {
return undefined return undefined;
} }
const payloadPayload = const payloadPayload =
"payload" in payload && "payload" in payload && typeof payload.payload === "object" && payload.payload !== null
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload ? payload.payload
: undefined : undefined;
let configLabelKey: string = key let configLabelKey: string = key;
if ( if (key in payload && typeof payload[key as keyof typeof payload] === "string") {
key in payload && configLabelKey = payload[key as keyof typeof payload] as string;
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string
} else if ( } else if (
payloadPayload && payloadPayload &&
key in payloadPayload && key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string" typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) { ) {
configLabelKey = payloadPayload[ configLabelKey = payloadPayload[key as keyof typeof payloadPayload] as string;
key as keyof typeof payloadPayload
] as string
} }
return configLabelKey in config return configLabelKey in config ? config[configLabelKey] : config[key as keyof typeof config];
? config[configLabelKey]
: config[key as keyof typeof config]
} }
export { export { ChartContainer, ChartTooltip, ChartTooltipContent, ChartLegend, ChartLegendContent, ChartStyle };
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

@@ -1,7 +1,4 @@
import React, { useEffect } from "react"; import type React from "react";
import Prism from "prismjs";
import "prismjs/themes/prism-twilight.css";
import "prismjs/components/prism-yaml";
import { toast } from "sonner"; import { toast } from "sonner";
import { copyToClipboard } from "~/utils/clipboard"; import { copyToClipboard } from "~/utils/clipboard";
@@ -11,35 +8,31 @@ interface CodeBlockProps {
filename?: string; filename?: string;
} }
export const CodeBlock: React.FC<CodeBlockProps> = ({ code, language = "jsx", filename }) => { export const CodeBlock: React.FC<CodeBlockProps> = ({ code, filename }) => {
useEffect(() => {
Prism.highlightAll();
}, []);
const handleCopy = async () => { const handleCopy = async () => {
await copyToClipboard(code); await copyToClipboard(code);
toast.success("Code copied to clipboard"); toast.success("Code copied to clipboard");
}; };
return ( return (
<div className="overflow-hidden rounded-sm bg-slate-900 ring-1 ring-white/10"> <div className="overflow-hidden rounded-sm bg-card-header ring-1 ring-white/10">
<div className="flex items-center justify-between border-b border-white/10 px-4 py-2 text-xs text-slate-400"> <div className="flex items-center justify-between border-b border-white/10 px-4 py-2 text-xs">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className="h-2.5 w-2.5 rounded-full bg-rose-500" /> <span className="h-2.5 w-2.5 rounded-full bg-rose-500" />
<span className="h-2.5 w-2.5 rounded-full bg-amber-500" /> <span className="h-2.5 w-2.5 rounded-full bg-amber-500" />
<span className="h-2.5 w-2.5 rounded-full bg-emerald-500" /> <span className="h-2.5 w-2.5 rounded-full bg-emerald-500" />
{filename && <span className="ml-3 font-medium text-slate-300">{filename}</span>} {filename && <span className="ml-3 font-medium">{filename}</span>}
</div> </div>
<button <button
type="button" type="button"
onClick={() => handleCopy()} onClick={() => handleCopy()}
className="cursor-pointer rounded-md bg-white/5 px-2 py-1 text-[11px] font-medium text-slate-300 ring-1 ring-inset ring-white/10 transition hover:bg-white/10 active:translate-y-px" className="cursor-pointer rounded-md bg-white/5 px-2 py-1 text-[11px] font-medium ring-1 ring-inset ring-white/10 transition hover:bg-white/10 active:translate-y-px"
> >
Copy Copy
</button> </button>
</div> </div>
<pre className="overflow-x-auto leading-6 text-xs m-0" style={{ marginTop: 0, marginBottom: 0 }}> <pre className="text-xs m-0 px-4 py-2 bg-card-header">
<code className={`language-${language}`}>{code}</code> <code className="text-white/80">{code}</code>
</pre> </pre>
</div> </div>
); );

View File

@@ -1,4 +1,4 @@
import * as React from "react"; import type * as React from "react";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
@@ -8,7 +8,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
type={type} type={type}
data-slot="input" data-slot="input"
className={cn( className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 border bg-transparent px-3 py-1 text-sm shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", "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", "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className, className,

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

@@ -1,64 +1,58 @@
import * as React from "react" import * as TabsPrimitive from "@radix-ui/react-tabs";
import * as TabsPrimitive from "@radix-ui/react-tabs" import type * as React from "react";
import { cn } from "~/lib/utils" import { cn } from "~/lib/utils";
function Tabs({ function Tabs({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Root>) {
className, return <TabsPrimitive.Root data-slot="tabs" className={cn("flex flex-col gap-2", className)} {...props} />;
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
} }
function TabsList({ function TabsList({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.List>) {
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
return ( return (
<TabsPrimitive.List <TabsPrimitive.List
data-slot="tabs-list" data-slot="tabs-list"
className={cn( className={cn("inline-flex h-7 items-center gap-4 text-xs text-muted-foreground", className)}
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
className
)}
{...props} {...props}
/> />
) );
} }
function TabsTrigger({ function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return ( return (
<TabsPrimitive.Trigger <TabsPrimitive.Trigger
data-slot="tabs-trigger" data-slot="tabs-trigger"
className={cn( className={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "cursor-pointer group relative inline-flex h-7 items-center whitespace-nowrap text-xs font-medium transition-colors",
className "text-muted-foreground data-[state=active]:text-foreground disabled:pointer-events-none disabled:opacity-50",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive/40 focus-visible:ring-offset-2 focus-visible:ring-offset-background",
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
// Padding: 20px horizontal (8px for bracket tick + 12px gap to text)
"px-5",
// Transparent orange background for active state
"data-[state=active]:bg-[#FF453A]/10",
// Left bracket - vertical line
"before:absolute before:left-0 before:top-0 before:h-7 before:w-0.5 before:bg-[#5D6570] before:transition-colors data-[state=active]:before:bg-[#FF453A]",
// Left bracket - top tick
"after:absolute after:left-0 after:top-[-1px] after:w-2 after:h-0.5 after:bg-[#5D6570] after:transition-colors data-[state=active]:after:bg-[#FF453A]",
className,
)} )}
{...props} {...props}
/> >
) <span className="relative z-10">{props.children}</span>
{/* Left bracket - bottom tick */}
<span className="absolute left-0 bottom-[-1px] h-0.5 w-2 bg-[#5D6570] transition-colors group-data-[state=active]:bg-[#FF453A]" />
{/* Right bracket - top tick */}
<span className="absolute right-0 top-[-1px] h-0.5 w-2 bg-[#5D6570] transition-colors group-data-[state=active]:bg-[#FF453A]" />
{/* Right bracket - vertical line */}
<span className="absolute right-0 top-0 h-7 w-0.5 bg-[#5D6570] transition-colors group-data-[state=active]:bg-[#FF453A]" />
{/* Right bracket - bottom tick */}
<span className="absolute right-0 bottom-[-1px] h-0.5 w-2 bg-[#5D6570] transition-colors group-data-[state=active]:bg-[#FF453A]" />
</TabsPrimitive.Trigger>
);
} }
function TabsContent({ function TabsContent({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Content>) {
className, return <TabsPrimitive.Content data-slot="tabs-content" className={cn("flex-1 outline-none", className)} {...props} />;
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
)
} }
export { Tabs, TabsList, TabsTrigger, TabsContent } export { Tabs, TabsList, TabsTrigger, TabsContent };

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

@@ -42,10 +42,10 @@ const getIconAndColor = (backend: BackendType) => {
}; };
export const VolumeIcon = ({ backend, size = 10 }: VolumeIconProps) => { export const VolumeIcon = ({ backend, size = 10 }: VolumeIconProps) => {
const { icon: Icon, color, label } = getIconAndColor(backend); const { icon: Icon, label } = getIconAndColor(backend);
return ( return (
<span className={`flex items-center gap-2 ${color} rounded-md px-2 py-1`}> <span className={`flex items-center gap-2 rounded-md px-2 py-1`}>
<Icon size={size} /> <Icon size={size} />
{label} {label}
</span> </span>

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[] { export function generateBreadcrumbs(pathname: string, params: Record<string, string | undefined>): BreadcrumbItem[] {
const breadcrumbs: BreadcrumbItem[] = []; const breadcrumbs: BreadcrumbItem[] = [];
// Always start with Home if (pathname.startsWith("/repositories")) {
breadcrumbs.push({ breadcrumbs.push({
label: "Volumes", label: "Repositories",
href: "/", href: "/repositories",
isCurrentPage: pathname === "/", 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: "/volumes",
isCurrentPage: pathname === "/volumes",
}); });
// Handle volume details page
if (pathname.startsWith("/volumes/") && params.name) { if (pathname.startsWith("/volumes/") && params.name) {
breadcrumbs.push({ breadcrumbs.push({
label: params.name, 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 Volume = GetVolumeResponse["volume"];
export type StatFs = GetVolumeResponse["statfs"]; export type StatFs = GetVolumeResponse["statfs"];
export type VolumeStatus = Volume["status"]; export type VolumeStatus = Volume["status"];
export type User = GetMeResponse["user"]; 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 { getMe, getStatus } from "~/api-client";
import { appContext } from "~/context"; import { appContext } from "~/context";
export const authMiddleware: MiddlewareFunction = async ({ context }) => { export const authMiddleware: MiddlewareFunction = async ({ context, request }) => {
const session = await getMe(); 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(); const status = await getStatus();
if (!status.data?.hasUsers) { if (!status.data?.hasUsers) {
throw redirect("/onboarding"); throw redirect("/onboarding");
@@ -14,5 +16,11 @@ export const authMiddleware: MiddlewareFunction = async ({ context }) => {
throw redirect("/login"); throw redirect("/login");
} }
if (session.data?.user?.id) {
context.set(appContext, { user: session.data.user, hasUsers: true }); 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

@@ -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(), ...updateVolumeMutation(),
onSuccess: (d) => { onSuccess: (d) => {
toast.success("Volume updated", { 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) => { onError: (error) => {

View File

@@ -18,7 +18,7 @@ export function StorageChart({ statfs }: Props) {
{ {
name: "Used", name: "Used",
value: statfs.used, value: statfs.used,
fill: "#2B7EFF", fill: "#ff543a",
}, },
{ {
name: "Free", name: "Free",
@@ -63,7 +63,7 @@ export function StorageChart({ statfs }: Props) {
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="flex-1 pb-0"> <CardContent className="flex-1 pb-0">
<div className=""> <div>
<ChartContainer config={chartConfig} className="mx-auto aspect-square max-h-[250px]"> <ChartContainer config={chartConfig} className="mx-auto aspect-square max-h-[250px]">
<PieChart> <PieChart>
<ChartTooltip <ChartTooltip
@@ -105,9 +105,9 @@ export function StorageChart({ statfs }: Props) {
<ByteSize bytes={statfs.total} className="font-mono text-sm" /> <ByteSize bytes={statfs.total} className="font-mono text-sm" />
</div> </div>
<div className="flex items-center justify-between p-3 rounded-lg bg-blue-500/10"> <div className="flex items-center justify-between p-3 rounded-lg bg-strong-accent/10">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="h-4 w-4 rounded-full bg-blue-500" /> <div className="h-4 w-4 rounded-full bg-strong-accent" />
<span className="font-medium">Used Space</span> <span className="font-medium">Used Space</span>
</div> </div>
<div className="text-right"> <div className="text-right">

View File

@@ -1,7 +1,7 @@
import { useMutation, useQuery } from "@tanstack/react-query"; 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 { toast } from "sonner";
import { getVolume } from "~/api-client"; import { useState } from "react";
import { import {
deleteVolumeMutation, deleteVolumeMutation,
getVolumeOptions, getVolumeOptions,
@@ -11,13 +11,23 @@ import {
import { StatusDot } from "~/components/status-dot"; import { StatusDot } from "~/components/status-dot";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs"; 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 { VolumeIcon } from "~/components/volume-icon";
import { parseError } from "~/lib/errors"; import { parseError } from "~/lib/errors";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
import { VolumeBackupsTabContent } from "~/modules/details/tabs/backups"; import type { Route } from "./+types/volume-details";
import { DockerTabContent } from "~/modules/details/tabs/docker"; import { getVolume } from "~/api-client";
import { VolumeInfoTabContent } from "~/modules/details/tabs/info"; import { VolumeInfoTabContent } from "../tabs/info";
import type { Route } from "./+types/details"; import { FilesTabContent } from "../tabs/files";
import { DockerTabContent } from "../tabs/docker";
export function meta({ params }: Route.MetaArgs) { export function meta({ params }: Route.MetaArgs) {
return [ return [
@@ -34,9 +44,12 @@ export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => {
if (volume.data) return volume.data; 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 { name } = useParams<{ name: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const activeTab = searchParams.get("tab") || "info";
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const { data } = useQuery({ const { data } = useQuery({
...getVolumeOptions({ path: { name: name ?? "" } }), ...getVolumeOptions({ path: { name: name ?? "" } }),
@@ -49,7 +62,7 @@ export default function DetailsPage({ loaderData }: Route.ComponentProps) {
...deleteVolumeMutation(), ...deleteVolumeMutation(),
onSuccess: () => { onSuccess: () => {
toast.success("Volume deleted successfully"); toast.success("Volume deleted successfully");
navigate("/"); navigate("/volumes");
}, },
onError: (error) => { onError: (error) => {
toast.error("Failed to delete volume", { toast.error("Failed to delete volume", {
@@ -82,10 +95,9 @@ export default function DetailsPage({ loaderData }: Route.ComponentProps) {
}, },
}); });
const handleDeleteConfirm = (name: string) => { const handleConfirmDelete = () => {
if (confirm(`Are you sure you want to delete the volume "${name}"? This action cannot be undone.`)) { setShowDeleteConfirm(false);
deleteVol.mutate({ path: { name } }); deleteVol.mutate({ path: { name: name ?? "" } });
}
}; };
if (!name) { if (!name) {
@@ -111,7 +123,6 @@ export default function DetailsPage({ loaderData }: Route.ComponentProps) {
</div> </div>
<div className="flex gap-4"> <div className="flex gap-4">
<Button <Button
variant="secondary"
onClick={() => mountVol.mutate({ path: { name } })} onClick={() => mountVol.mutate({ path: { name } })}
loading={mountVol.isPending} loading={mountVol.isPending}
className={cn({ hidden: volume.status === "mounted" })} className={cn({ hidden: volume.status === "mounted" })}
@@ -126,27 +137,47 @@ export default function DetailsPage({ loaderData }: Route.ComponentProps) {
> >
Unmount Unmount
</Button> </Button>
<Button variant="destructive" onClick={() => handleDeleteConfirm(name)} disabled={deleteVol.isPending}> <Button variant="destructive" onClick={() => setShowDeleteConfirm(true)} disabled={deleteVol.isPending}>
Delete Delete
</Button> </Button>
</div> </div>
</div> </div>
<Tabs defaultValue="info" className="mt-0"> <Tabs value={activeTab} onValueChange={(value) => setSearchParams({ tab: value })} className="mt-4">
<TabsList> <TabsList className="mb-2">
<TabsTrigger value="info">Configuration</TabsTrigger> <TabsTrigger value="info">Configuration</TabsTrigger>
<TabsTrigger value="files">Files</TabsTrigger>
<TabsTrigger value="docker">Docker</TabsTrigger> <TabsTrigger value="docker">Docker</TabsTrigger>
<TabsTrigger value="backups">Backups</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="info"> <TabsContent value="info">
<VolumeInfoTabContent volume={volume} statfs={statfs} /> <VolumeInfoTabContent volume={volume} statfs={statfs} />
</TabsContent> </TabsContent>
<TabsContent value="files">
<FilesTabContent volume={volume} />
</TabsContent>
<TabsContent value="docker"> <TabsContent value="docker">
<DockerTabContent volume={volume} /> <DockerTabContent volume={volume} />
</TabsContent> </TabsContent>
<TabsContent value="backups">
<VolumeBackupsTabContent volume={volume} />
</TabsContent>
</Tabs> </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,17 +1,19 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Copy, RotateCcw } from "lucide-react"; import { HardDrive, RotateCcw } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { listVolumes } from "~/api-client"; import { listVolumes } from "~/api-client";
import { listVolumesOptions } from "~/api-client/@tanstack/react-query.gen"; import { listVolumesOptions } from "~/api-client/@tanstack/react-query.gen";
import { CreateVolumeDialog } from "~/components/create-volume-dialog"; import { CreateVolumeDialog } from "~/components/create-volume-dialog";
import { EmptyState } from "~/components/empty-state";
import { StatusDot } from "~/components/status-dot"; import { StatusDot } from "~/components/status-dot";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Card } from "~/components/ui/card";
import { Input } from "~/components/ui/input"; import { Input } from "~/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select";
import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "~/components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/components/ui/table";
import { VolumeIcon } from "~/components/volume-icon"; import { VolumeIcon } from "~/components/volume-icon";
import type { Route } from "./+types/home"; import type { Route } from "./+types/volumes";
export function meta(_: Route.MetaArgs) { export function meta(_: Route.MetaArgs) {
return [ return [
@@ -25,11 +27,11 @@ export function meta(_: Route.MetaArgs) {
export const clientLoader = async () => { export const clientLoader = async () => {
const volumes = await listVolumes(); const volumes = await listVolumes();
if (volumes.data) return { volumes: volumes.data.volumes }; if (volumes.data) return volumes.data;
return { volumes: [] }; return [];
}; };
export default function Home({ loaderData }: Route.ComponentProps) { export default function Volumes({ loaderData }: Route.ComponentProps) {
const [createVolumeOpen, setCreateVolumeOpen] = useState(false); const [createVolumeOpen, setCreateVolumeOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [statusFilter, setStatusFilter] = useState(""); const [statusFilter, setStatusFilter] = useState("");
@@ -51,29 +53,39 @@ export default function Home({ loaderData }: Route.ComponentProps) {
}); });
const filteredVolumes = const filteredVolumes =
data?.volumes.filter((volume) => { data.filter((volume) => {
const matchesSearch = volume.name.toLowerCase().includes(searchQuery.toLowerCase()); const matchesSearch = volume.name.toLowerCase().includes(searchQuery.toLowerCase());
const matchesStatus = !statusFilter || volume.status === statusFilter; const matchesStatus = !statusFilter || volume.status === statusFilter;
const matchesBackend = !backendFilter || volume.type === backendFilter; const matchesBackend = !backendFilter || volume.type === backendFilter;
return matchesSearch && matchesStatus && matchesBackend; return matchesSearch && matchesStatus && matchesBackend;
}) || []; }) || [];
const hasNoVolumes = data.length === 0;
const hasNoFilteredVolumes = filteredVolumes.length === 0 && !hasNoVolumes;
if (hasNoVolumes) {
return ( return (
<> <EmptyState
<h1 className="text-2xl sm:text-3xl font-bold mb-0 uppercase">Ironmount</h1> icon={HardDrive}
<h2 className="text-xs sm:text-sm font-semibold mb-2 text-muted-foreground"> title="No volume"
Create, manage, monitor, and automate your volumes with ease. description="Manage and monitor all your storage backends in one place with advanced features like automatic mounting and health checks."
</h2> button={<CreateVolumeDialog open={createVolumeOpen} setOpen={setCreateVolumeOpen} />}
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2 mt-4 sm:justify-between"> />
<span className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2"> );
}
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 <Input
className="w-full sm:w-[180px]" className="w-full lg:w-[180px] min-w-[180px] mr-[-1px] mt-[-1px]"
placeholder="Search volumes…" placeholder="Search volumes…"
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
/> />
<Select value={statusFilter} onValueChange={setStatusFilter}> <Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-full sm:w-[180px]"> <SelectTrigger className="w-full lg:w-[180px] min-w-[180px] mr-[-1px] mt-[-1px]">
<SelectValue placeholder="All status" /> <SelectValue placeholder="All status" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -83,7 +95,7 @@ export default function Home({ loaderData }: Route.ComponentProps) {
</SelectContent> </SelectContent>
</Select> </Select>
<Select value={backendFilter} onValueChange={setBackendFilter}> <Select value={backendFilter} onValueChange={setBackendFilter}>
<SelectTrigger className="w-full sm:w-[180px]"> <SelectTrigger className="w-full lg:w-[180px] min-w-[180px] mt-[-1px]">
<SelectValue placeholder="All backends" /> <SelectValue placeholder="All backends" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -93,7 +105,7 @@ export default function Home({ loaderData }: Route.ComponentProps) {
</SelectContent> </SelectContent>
</Select> </Select>
{(searchQuery || statusFilter || backendFilter) && ( {(searchQuery || statusFilter || backendFilter) && (
<Button variant="outline" size="sm" onClick={clearFilters} className="w-full sm:w-auto"> <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" /> <RotateCcw className="h-4 w-4 mr-2" />
Clear filters Clear filters
</Button> </Button>
@@ -101,44 +113,58 @@ export default function Home({ loaderData }: Route.ComponentProps) {
</span> </span>
<CreateVolumeDialog open={createVolumeOpen} setOpen={setCreateVolumeOpen} /> <CreateVolumeDialog open={createVolumeOpen} setOpen={setCreateVolumeOpen} />
</div> </div>
<div className="mt-4 overflow-x-auto"> <div className="overflow-x-auto">
<Table className="border bg-white dark:bg-secondary"> <Table className="border-t">
<TableCaption>A list of your managed volumes.</TableCaption> <TableHeader className="bg-card-header">
<TableHeader>
<TableRow> <TableRow>
<TableHead className="w-[100px] uppercase">Name</TableHead> <TableHead className="w-[100px] uppercase">Name</TableHead>
<TableHead className="uppercase text-left">Backend</TableHead> <TableHead className="uppercase text-left">Backend</TableHead>
<TableHead className="uppercase hidden sm:table-cell">Mountpoint</TableHead>
<TableHead className="uppercase text-center">Status</TableHead> <TableHead className="uppercase text-center">Status</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{filteredVolumes.map((volume) => ( {hasNoFilteredVolumes ? (
<TableRow>
<TableCell colSpan={4} className="text-center py-12">
<div className="flex flex-col items-center gap-3">
<p className="text-muted-foreground">No volumes 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>
) : (
filteredVolumes.map((volume) => (
<TableRow <TableRow
key={volume.name} key={volume.name}
className="hover:bg-accent/50 hover:cursor-pointer" className="hover:bg-accent/50 hover:cursor-pointer"
onClick={() => navigate(`/volumes/${volume.name}`)} onClick={() => navigate(`/volumes/${volume.name}`)}
> >
<TableCell className="font-medium">{volume.name}</TableCell> <TableCell className="font-medium text-strong-accent">{volume.name}</TableCell>
<TableCell> <TableCell>
<VolumeIcon backend={volume.type} /> <VolumeIcon backend={volume.type} />
</TableCell> </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"> <TableCell className="text-center">
<StatusDot status={volume.status} /> <StatusDot status={volume.status} />
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ))
)}
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
</> <div className="px-4 py-2 text-sm text-muted-foreground bg-card-header flex justify-end border-t">
{hasNoFilteredVolumes ? (
"No volumes match filters."
) : (
<span>
<span className="text-strong-accent">{filteredVolumes.length}</span> volume
{filteredVolumes.length > 1 ? "s" : ""}
</span>
)}
</div>
</Card>
); );
} }

View File

@@ -38,7 +38,7 @@ export const DockerTabContent = ({ volume }: Props) => {
refetchOnWindowFocus: true, refetchOnWindowFocus: true,
}); });
const containers = containersData?.containers || []; const containers = containersData || [];
const getStateClass = (state: string) => { const getStateClass = (state: string) => {
switch (state) { 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 [pendingValues, setPendingValues] = useState<FormValues | null>(null);
const handleSubmit = (values: FormValues) => { const handleSubmit = (values: FormValues) => {
console.log({ values });
setPendingValues(values); setPendingValues(values);
setOpen(true); setOpen(true);
}; };

View File

@@ -45,7 +45,7 @@ export function Layout({ children }: { children: React.ReactNode }) {
<Links /> <Links />
</head> </head>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<body className="min-h-dvh"> <body>
{children} {children}
<Toaster /> <Toaster />
<ScrollRestoration /> <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 [ export default [
route("onboarding", "./routes/onboarding.tsx"), route("onboarding", "./modules/auth/routes/onboarding.tsx"),
route("login", "./routes/login.tsx"), route("login", "./modules/auth/routes/login.tsx"),
layout("./components/layout.tsx", [index("./routes/home.tsx"), route("volumes/:name", "./routes/details.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; ] satisfies RouteConfig;

View File

@@ -1,102 +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 { 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 (
<div className="min-h-screen 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>
</div>
);
}

View File

@@ -1,132 +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 { 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 (
<div className="min-h-screen 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 (2-50 characters).</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>
</div>
);
}

View File

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

View File

@@ -0,0 +1,14 @@
export function deepClean<T>(obj: T): T {
if (Array.isArray(obj)) {
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 && cleaned !== "") acc[key as keyof T] = cleaned;
return acc;
}, {} as T);
}
return obj;
}

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", "@hookform/resolvers": "^5.2.2",
"@ironmount/schemas": "workspace:*", "@ironmount/schemas": "workspace:*",
"@radix-ui/react-alert-dialog": "^1.1.15", "@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-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.7", "@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6", "@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",
@@ -25,14 +27,15 @@
"@tanstack/react-query": "^5.90.2", "@tanstack/react-query": "^5.90.2",
"@tanstack/react-query-devtools": "^5.90.2", "@tanstack/react-query-devtools": "^5.90.2",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"arktype": "^2.1.22", "arktype": "^2.1.23",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cron-parser": "^5.4.0",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"dither-plugin": "^1.1.1",
"isbot": "^5.1.31", "isbot": "^5.1.31",
"lucide-react": "^0.544.0", "lucide-react": "^0.546.0",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"prismjs": "^1.30.0",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-hook-form": "^7.63.0", "react-hook-form": "^7.63.0",
@@ -46,11 +49,11 @@
"@react-router/dev": "^7.9.3", "@react-router/dev": "^7.9.3",
"@tailwindcss/vite": "^4.1.14", "@tailwindcss/vite": "^4.1.14",
"@types/node": "^24.6.2", "@types/node": "^24.6.2",
"@types/prismjs": "^1.26.5",
"@types/react": "^19.2.0", "@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0", "@types/react-dom": "^19.2.0",
"lightningcss": "^1.30.2", "lightningcss": "^1.30.2",
"tailwindcss": "^4.1.14", "tailwindcss": "^4.1.14",
"tinyglobby": "^0.2.15",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vite": "^7.1.9", "vite": "^7.1.9",

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, "when": 1759416698274,
"tag": "0005_simple_alice", "tag": "0005_simple_alice",
"breakpoints": true "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": { "scripts": {
"dev": "bun run --watch src/index.ts", "dev": "bun run --watch src/index.ts",
"build": "rm -rf dist && bun build.ts", "build": "rm -rf dist && bun build.ts",
"tsc": "tsc --noEmit", "tsc": "tsc --noEmit"
"gen:migrations": "drizzle-kit generate",
"studio": "drizzle-kit studio"
}, },
"dependencies": { "dependencies": {
"@hono/arktype-validator": "^2.0.1",
"@hono/standard-validator": "^0.1.5", "@hono/standard-validator": "^0.1.5",
"@ironmount/schemas": "workspace:*", "@ironmount/schemas": "workspace:*",
"@scalar/hono-api-reference": "^0.9.13", "@scalar/hono-api-reference": "^0.9.13",
"arktype": "^2.1.20", "arktype": "^2.1.23",
"cron-parser": "^5.4.0",
"dockerode": "^4.0.8", "dockerode": "^4.0.8",
"dotenv": "^17.2.1", "dotenv": "^17.2.1",
"drizzle-orm": "^0.44.6", "drizzle-orm": "^0.44.6",
@@ -26,8 +24,7 @@
}, },
"devDependencies": { "devDependencies": {
"@libsql/client": "^0.15.15", "@libsql/client": "^0.15.15",
"@types/bun": "^1.2.20", "@types/bun": "^1.3.0",
"@types/dockerode": "^3.3.44", "@types/dockerode": "^3.3.44"
"drizzle-kit": "^0.31.5"
} }
} }

View File

@@ -1,3 +1,5 @@
export const OPERATION_TIMEOUT = 5000; 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 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"; import * as schema from "./schema";
const sqlite = new Database(DATABASE_URL); const sqlite = new Database(DATABASE_URL);
sqlite.run("PRAGMA foreign_keys = ON;");
export const db = drizzle({ client: sqlite, schema }); export const db = drizzle({ client: sqlite, schema });

View File

@@ -1,40 +1,107 @@
import type { BackendStatus, BackendType, volumeConfigSchema } from "@ironmount/schemas"; import type { BackendStatus, BackendType, volumeConfigSchema } from "@ironmount/schemas";
import { sql } from "drizzle-orm"; import type {
import { int, sqliteTable, text } from "drizzle-orm/sqlite-core"; CompressionMode,
RepositoryBackend,
repositoryConfigSchema,
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", { export const volumesTable = sqliteTable("volumes_table", {
id: int().primaryKey({ autoIncrement: true }), id: int().primaryKey({ autoIncrement: true }),
name: text().notNull().unique(), name: text().notNull().unique(),
path: text().notNull(),
type: text().$type<BackendType>().notNull(), type: text().$type<BackendType>().notNull(),
status: text().$type<BackendStatus>().notNull().default("unmounted"), status: text().$type<BackendStatus>().notNull().default("unmounted"),
lastError: text("last_error"), lastError: text("last_error"),
lastHealthCheck: int("last_health_check", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`), lastHealthCheck: integer("last_health_check", { mode: "number" }).notNull().default(sql`(unixepoch())`),
createdAt: int("created_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`), createdAt: integer("created_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
updatedAt: int("updated_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`), updatedAt: integer("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
config: text("config", { mode: "json" }).$type<typeof volumeConfigSchema.inferOut>().notNull(), config: text("config", { mode: "json" }).$type<typeof volumeConfigSchema.inferOut>().notNull(),
autoRemount: int("auto_remount", { mode: "boolean" }).notNull().default(true), autoRemount: int("auto_remount", { mode: "boolean" }).notNull().default(true),
}); });
export type Volume = typeof volumesTable.$inferSelect; export type Volume = typeof volumesTable.$inferSelect;
/**
* Users Table
*/
export const usersTable = sqliteTable("users_table", { export const usersTable = sqliteTable("users_table", {
id: int().primaryKey({ autoIncrement: true }), id: int().primaryKey({ autoIncrement: true }),
username: text().notNull().unique(), username: text().notNull().unique(),
passwordHash: text("password_hash").notNull(), passwordHash: text("password_hash").notNull(),
createdAt: int("created_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`), createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
updatedAt: int("updated_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`), updatedAt: int("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
}); });
export type User = typeof usersTable.$inferSelect; export type User = typeof usersTable.$inferSelect;
export const sessionsTable = sqliteTable("sessions_table", { export const sessionsTable = sqliteTable("sessions_table", {
id: text().primaryKey(), id: text().primaryKey(),
userId: int("user_id") userId: int("user_id")
.notNull() .notNull()
.references(() => usersTable.id, { onDelete: "cascade" }), .references(() => usersTable.id, { onDelete: "cascade" }),
expiresAt: int("expires_at", { mode: "timestamp" }).notNull(), expiresAt: int("expires_at", { mode: "number" }).notNull(),
createdAt: int("created_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`), createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
}); });
export type Session = typeof sessionsTable.$inferSelect; export type Session = typeof sessionsTable.$inferSelect;
/**
* Repositories Table
*/
export const repositoriesTable = sqliteTable("repositories_table", {
id: text().primaryKey(),
name: text().notNull().unique(),
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: "number" }),
lastError: text("last_error"),
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 { requireAuth } from "./modules/auth/auth.middleware";
import { driverController } from "./modules/driver/driver.controller"; import { driverController } from "./modules/driver/driver.controller";
import { startup } from "./modules/lifecycle/startup"; import { startup } from "./modules/lifecycle/startup";
import { repositoriesController } from "./modules/repositories/repositories.controller";
import { volumeController } from "./modules/volumes/volume.controller"; import { volumeController } from "./modules/volumes/volume.controller";
import { backupScheduleController } from "./modules/backups/backups.controller";
import { handleServiceError } from "./utils/errors"; import { handleServiceError } from "./utils/errors";
import { logger } from "./utils/logger"; import { logger } from "./utils/logger";
@@ -21,7 +23,7 @@ export const generalDescriptor = (app: Hono) =>
version: "1.0.0", version: "1.0.0",
description: "API for managing volumes", 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" })) .get("healthcheck", (c) => c.json({ status: "ok" }))
.route("/api/v1/auth", authController.basePath("/api/v1")) .route("/api/v1/auth", authController.basePath("/api/v1"))
.route("/api/v1/volumes", volumeController.use(requireAuth)) .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("/assets/*", serveStatic({ root: "./assets/frontend" }))
.get("*", serveStatic({ path: "./assets/frontend/index.html" })); .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, logoutDto,
registerBodySchema, registerBodySchema,
registerDto, registerDto,
type GetMeDto,
type GetStatusDto,
type LoginDto,
type LogoutDto,
type RegisterDto,
} from "./auth.dto"; } from "./auth.dto";
import { authService } from "./auth.service"; import { authService } from "./auth.service";
import { toMessage } from "../../utils/errors";
const COOKIE_NAME = "session_id"; const COOKIE_NAME = "session_id";
const COOKIE_OPTIONS = { const COOKIE_OPTIONS = {
@@ -33,9 +39,12 @@ export const authController = new Hono()
expires: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days 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) { } 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) => { .post("/login", loginDto, validator("json", loginBodySchema), async (c) => {
@@ -46,15 +55,16 @@ export const authController = new Hono()
setCookie(c, COOKIE_NAME, sessionId, { setCookie(c, COOKIE_NAME, sessionId, {
...COOKIE_OPTIONS, ...COOKIE_OPTIONS,
expires: expiresAt, expires: new Date(expiresAt),
}); });
return c.json({ return c.json<LoginDto>({
success: true,
message: "Login successful", message: "Login successful",
user: { id: user.id, username: user.username }, user: { id: user.id, username: user.username },
}); });
} catch (error) { } 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) => { .post("/logout", logoutDto, async (c) => {
@@ -65,13 +75,13 @@ export const authController = new Hono()
deleteCookie(c, COOKIE_NAME, COOKIE_OPTIONS); deleteCookie(c, COOKIE_NAME, COOKIE_OPTIONS);
} }
return c.json({ message: "Logout successful" }); return c.json<LogoutDto>({ success: true });
}) })
.get("/me", getMeDto, async (c) => { .get("/me", getMeDto, async (c) => {
const sessionId = getCookie(c, COOKIE_NAME); const sessionId = getCookie(c, COOKIE_NAME);
if (!sessionId) { 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); 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({ message: "Not authenticated" }, 401);
} }
return c.json({ return c.json<GetMeDto>({
success: true,
user: session.user, user: session.user,
message: "Authenticated",
}); });
}) })
.get("/status", getStatusDto, async (c) => { .get("/status", getStatusDto, async (c) => {
const hasUsers = await authService.hasUsers(); 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({ const loginResponseSchema = type({
message: "string", message: "string",
success: "boolean",
user: type({ user: type({
id: "string", id: "number",
username: "string", username: "string",
}), }).optional(),
}); });
export const loginDto = describeRoute({ export const loginDto = describeRoute({
@@ -39,6 +40,8 @@ export const loginDto = describeRoute({
}, },
}); });
export type LoginDto = typeof loginResponseSchema.infer;
export const registerDto = describeRoute({ export const registerDto = describeRoute({
description: "Register a new user", description: "Register a new user",
operationId: "register", 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({ export const logoutDto = describeRoute({
description: "Logout current user", description: "Logout current user",
operationId: "logout", operationId: "logout",
@@ -67,13 +76,15 @@ export const logoutDto = describeRoute({
description: "Logout successful", description: "Logout successful",
content: { content: {
"application/json": { "application/json": {
schema: resolver(type({ message: "string" })), schema: resolver(logoutResponseSchema),
}, },
}, },
}, },
}, },
}); });
export type LogoutDto = typeof logoutResponseSchema.infer;
export const getMeDto = describeRoute({ export const getMeDto = describeRoute({
description: "Get current authenticated user", description: "Get current authenticated user",
operationId: "getMe", operationId: "getMe",
@@ -87,12 +98,11 @@ export const getMeDto = describeRoute({
}, },
}, },
}, },
401: {
description: "Not authenticated",
},
}, },
}); });
export type GetMeDto = typeof loginResponseSchema.infer;
const statusResponseSchema = type({ const statusResponseSchema = type({
hasUsers: "boolean", hasUsers: "boolean",
}); });
@@ -113,5 +123,7 @@ export const getStatusDto = describeRoute({
}, },
}); });
export type GetStatusDto = typeof statusResponseSchema.infer;
export type LoginBody = typeof loginBodySchema.infer; export type LoginBody = typeof loginBodySchema.infer;
export type RegisterBody = typeof registerBodySchema.infer; export type RegisterBody = typeof registerBodySchema.infer;

View File

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

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