mirror of
https://github.com/nicotsx/ironmount.git
synced 2025-12-10 12:10:51 +01:00
Compare commits
72 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f1e24a034 | ||
|
|
1e68339dd9 | ||
|
|
941be2f306 | ||
|
|
99d4d46338 | ||
|
|
ab997ef450 | ||
|
|
9ee5871fbb | ||
|
|
01c2a3669c | ||
|
|
ecd517341c | ||
|
|
ef27ab8ed8 | ||
|
|
d1e46918ec | ||
|
|
11ca80a929 | ||
|
|
f2643436b0 | ||
|
|
acc5f44565 | ||
|
|
ccfa5e35e9 | ||
|
|
c2041932b5 | ||
|
|
44917f3513 | ||
|
|
3e514e61db | ||
|
|
67e7d36fe7 | ||
|
|
8f447ac58d | ||
|
|
3befa127d7 | ||
|
|
18115b374c | ||
|
|
d81f3653ec | ||
|
|
c64e50bdec | ||
|
|
afeaf87bb0 | ||
|
|
ee79fce2aa | ||
|
|
c7db88fb56 | ||
|
|
5846c1ff86 | ||
|
|
b80a187108 | ||
|
|
ed73ca73fb | ||
|
|
bd168df352 | ||
|
|
cce2d356fe | ||
|
|
9628310d53 | ||
|
|
e335133237 | ||
|
|
b188a84af3 | ||
|
|
d1c1adaba7 | ||
|
|
37a22b260f | ||
|
|
a7bc1c2e7e | ||
|
|
43e31596f1 | ||
|
|
2202ad3247 | ||
|
|
47ff720adb | ||
|
|
d58c4f793d | ||
|
|
f7718055eb | ||
|
|
cae8538b2e | ||
|
|
4ae738ce41 | ||
|
|
8b1438ea62 | ||
|
|
9d10e48da6 | ||
|
|
a927411c0d | ||
|
|
a64de8ec78 | ||
|
|
6960b4d71e | ||
|
|
06cb401cf7 | ||
|
|
0090c3c43c | ||
|
|
e07c22a7d4 | ||
|
|
100c24de13 | ||
|
|
6e8aa4b465 | ||
|
|
ad54948a69 | ||
|
|
5b6a86331e | ||
|
|
8fcc9ada74 | ||
|
|
8a9d5fc3c8 | ||
|
|
219dec1c9c | ||
|
|
3bda6e81ae | ||
|
|
269116c25e | ||
|
|
c8fc5a1273 | ||
|
|
ae592481af | ||
|
|
65a7f436fe | ||
|
|
8af0bac63b | ||
|
|
41756e087a | ||
|
|
71ca5d3309 | ||
|
|
e29908757f | ||
|
|
15f0dc637d | ||
|
|
d16be6cbca | ||
|
|
1e3419c250 | ||
|
|
a5e0fb6aa2 |
@@ -18,3 +18,8 @@
|
|||||||
!apps/**/public/**
|
!apps/**/public/**
|
||||||
|
|
||||||
!packages/**/src/**
|
!packages/**/src/**
|
||||||
|
|
||||||
|
# License files and attributions
|
||||||
|
!LICENSE
|
||||||
|
!NOTICES.md
|
||||||
|
!LICENSES/**
|
||||||
|
|||||||
20
.github/workflows/release.yml
vendored
20
.github/workflows/release.yml
vendored
@@ -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
4
.gitignore
vendored
@@ -41,3 +41,7 @@ node_modules/
|
|||||||
.turbo
|
.turbo
|
||||||
|
|
||||||
mutagen.yml.lock
|
mutagen.yml.lock
|
||||||
|
|
||||||
|
data/
|
||||||
|
|
||||||
|
CLAUDE.md
|
||||||
|
|||||||
41
Dockerfile
41
Dockerfile
@@ -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"]
|
||||||
|
|
||||||
|
|||||||
25
LICENSES/BSD-2-Clause-Restic.txt
Normal file
25
LICENSES/BSD-2-Clause-Restic.txt
Normal 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
29
NOTICES.md
Normal 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.
|
||||||
27
README.md
27
README.md
@@ -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
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Volume creation
|
## Third-Party Software
|
||||||
|
|
||||||

|
This project includes the following third-party software components:
|
||||||
|
|
||||||
|
### Restic
|
||||||
|
|
||||||
|
Ironmount includes [Restic](https://github.com/restic/restic) for backup functionality.
|
||||||
|
|
||||||
|
- **License**: BSD 2-Clause License
|
||||||
|
- **Copyright**: Copyright (c) 2014, Alexander Neumann <alexander@bumpern.de>
|
||||||
|
- **Status**: Included unchanged
|
||||||
|
- **License Text**: See [LICENSES/BSD-2-Clause-Restic.txt](LICENSES/BSD-2-Clause-Restic.txt)
|
||||||
|
|
||||||
|
For a complete list of third-party software licenses and attributions, please refer to the [NOTICES.md](NOTICES.md) file.
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
|
|||||||
@@ -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",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@@ -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 & {});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 *));
|
||||||
|
|
||||||
@@ -15,12 +16,19 @@ body {
|
|||||||
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);
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ 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();
|
||||||
@@ -17,17 +16,12 @@ export function AppBreadcrumb() {
|
|||||||
<Breadcrumb>
|
<Breadcrumb>
|
||||||
<BreadcrumbLink asChild></BreadcrumbLink>
|
<BreadcrumbLink asChild></BreadcrumbLink>
|
||||||
<BreadcrumbList>
|
<BreadcrumbList>
|
||||||
{breadcrumbs.length === 1 && (
|
|
||||||
<BreadcrumbItem>
|
|
||||||
<Link to="/">Ironmount</Link>
|
|
||||||
</BreadcrumbItem>
|
|
||||||
)}
|
|
||||||
{breadcrumbs.map((breadcrumb, index) => {
|
{breadcrumbs.map((breadcrumb, index) => {
|
||||||
const isLast = index === breadcrumbs.length - 1;
|
const isLast = index === breadcrumbs.length - 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={`${breadcrumb.label}-${index}`} className="contents">
|
<div key={`${breadcrumb.label}-${index}`} className="contents">
|
||||||
<BreadcrumbItem className={cn({ invisible: breadcrumbs.length <= 1 })}>
|
<BreadcrumbItem>
|
||||||
{isLast || breadcrumb.isCurrentPage ? (
|
{isLast || breadcrumb.isCurrentPage ? (
|
||||||
<BreadcrumbPage>{breadcrumb.label}</BreadcrumbPage>
|
<BreadcrumbPage>{breadcrumb.label}</BreadcrumbPage>
|
||||||
) : breadcrumb.href ? (
|
) : breadcrumb.href ? (
|
||||||
|
|||||||
85
apps/client/app/components/app-sidebar.tsx
Normal file
85
apps/client/app/components/app-sidebar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
apps/client/app/components/auth-layout.tsx
Normal file
34
apps/client/app/components/auth-layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
66
apps/client/app/components/create-repository-dialog.tsx
Normal file
66
apps/client/app/components/create-repository-dialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
206
apps/client/app/components/create-repository-form.tsx
Normal file
206
apps/client/app/components/create-repository-form.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -39,7 +39,7 @@ export const CreateVolumeDialog = ({ open, setOpen }: Props) => {
|
|||||||
</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>
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -311,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 />
|
||||||
@@ -325,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 />
|
||||||
@@ -339,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 />
|
||||||
@@ -353,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 />
|
||||||
@@ -450,11 +452,10 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
|
|||||||
</div>
|
</div>
|
||||||
{testMessage && (
|
{testMessage && (
|
||||||
<div
|
<div
|
||||||
className={`text-xs p-2 rounded-md ${
|
className={cn("text-xs p-2 rounded-md text-wrap wrap-anywhere", {
|
||||||
testMessage.success
|
"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,
|
||||||
: "bg-red-50 text-red-700 border border-red-200"
|
})}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{testMessage.message}
|
{testMessage.message}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,56 +1,32 @@
|
|||||||
import { Database, HardDrive, HeartPulse, Plus } from "lucide-react";
|
import { Card } from "./ui/card";
|
||||||
import { CreateVolumeDialog } from "./create-volume-dialog";
|
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
export function EmptyState() {
|
type EmptyStateProps = {
|
||||||
const [createVolumeOpen, setCreateVolumeOpen] = useState(false);
|
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 (
|
return (
|
||||||
|
<Card className="p-0 gap-0">
|
||||||
<div className="flex flex-col items-center justify-center py-16 px-4 text-center">
|
<div className="flex flex-col items-center justify-center py-16 px-4 text-center">
|
||||||
<div className="relative mb-8">
|
<div className="relative mb-8">
|
||||||
<div className="absolute inset-0 animate-pulse">
|
<div className="absolute inset-0 animate-pulse">
|
||||||
<div className="w-32 h-32 rounded-full bg-primary/10 blur-2xl" />
|
<div className="w-32 h-32 rounded-full bg-primary/10 blur-2xl" />
|
||||||
</div>
|
</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">
|
<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} />
|
<Cicon className="w-16 h-16 text-primary/70" strokeWidth={1.5} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="max-w-md space-y-3 mb-8">
|
<div className="max-w-md space-y-3 mb-8">
|
||||||
<h3 className="text-2xl font-semibold text-foreground">No volumes yet</h3>
|
<h3 className="text-2xl font-semibold text-foreground">{title}</h3>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground text-sm">{description}</p>
|
||||||
Get started by creating your first volume. Manage and monitor all your storage backends in one place with
|
|
||||||
advanced features like automatic mounting and health checks.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<CreateVolumeDialog open={createVolumeOpen} setOpen={setCreateVolumeOpen} />
|
|
||||||
|
|
||||||
<div className="mt-12 grid grid-cols-1 md:grid-cols-3 gap-0 max-w-3xl">
|
|
||||||
<div className="flex flex-col items-center gap-2 p-4 border bg-card-header">
|
|
||||||
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center">
|
|
||||||
<Database className="w-5 h-5 text-primary" />
|
|
||||||
</div>
|
|
||||||
<h4 className="font-medium text-sm">Multiple Backends</h4>
|
|
||||||
<p className="text-xs text-muted-foreground">Support for local, NFS, and SMB storage</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col items-center gap-2 p-4 border border-r-0 border-l-0 bg-card-header">
|
|
||||||
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center">
|
|
||||||
<HardDrive className="w-5 h-5 text-primary" />
|
|
||||||
</div>
|
|
||||||
<h4 className="font-medium text-sm">Auto Mounting</h4>
|
|
||||||
<p className="text-xs text-muted-foreground">Automatic lifecycle management</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col items-center gap-2 p-4 border bg-card-header">
|
|
||||||
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center">
|
|
||||||
<HeartPulse className="w-5 h-5 text-primary" />
|
|
||||||
</div>
|
|
||||||
<h4 className="font-medium text-sm">Real-time Monitoring</h4>
|
|
||||||
<p className="text-xs text-muted-foreground">Live status and health checks</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
{button}
|
||||||
</div>
|
</div>
|
||||||
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
563
apps/client/app/components/file-tree.tsx
Normal file
563
apps/client/app/components/file-tree.tsx
Normal 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" });
|
||||||
|
}
|
||||||
@@ -11,15 +11,14 @@ export function GridBackground({ children, className, containerClassName }: Grid
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative min-h-dvh w-full overflow-x-hidden",
|
"relative min-h-full w-full overflow-x-hidden",
|
||||||
"[background-size:20px_20px] sm:[background-size:40px_40px]",
|
"[background-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)]",
|
"[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)]",
|
"dark:[background-image:linear-gradient(to_right,#262626_1px,transparent_1px),linear-gradient(to_bottom,#262626_1px,transparent_1px)]",
|
||||||
containerClassName,
|
containerClassName,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-white [mask-image:radial-gradient(ellipse_at_center,transparent_20%,black)] dark:bg-card" />
|
<div className={cn("relative container m-auto", className)}>{children}</div>
|
||||||
<div className={cn("relative h-screen", className)}>{children}</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
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 { logoutMutation } from "~/api-client/@tanstack/react-query.gen";
|
import { logoutMutation } from "~/api-client/@tanstack/react-query.gen";
|
||||||
@@ -8,6 +9,8 @@ import type { Route } from "./+types/layout";
|
|||||||
import { AppBreadcrumb } from "./app-breadcrumb";
|
import { AppBreadcrumb } from "./app-breadcrumb";
|
||||||
import { GridBackground } from "./grid-background";
|
import { GridBackground } from "./grid-background";
|
||||||
import { Button } from "./ui/button";
|
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,30 +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 (
|
||||||
<GridBackground>
|
<SidebarProvider defaultOpen={true}>
|
||||||
<header className="bg-card-header border-b border-border/50">
|
<AppSidebar />
|
||||||
<div className="flex items-center justify-between py-3 sm:py-4 px-2 sm:px-4 container mx-auto">
|
<div className="w-full relative flex flex-col h-screen overflow-hidden">
|
||||||
|
<header className="z-50 bg-card-header border-b border-border/50 flex-shrink-0">
|
||||||
|
<div className="flex items-center justify-between py-3 sm:py-4 px-2 sm:px-8 mx-auto container">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<SidebarTrigger />
|
||||||
<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">
|
<span className="text-sm text-muted-foreground hidden md:inline-flex">
|
||||||
Welcome, <span className="text-strong-accent">{loaderData.user?.username}</span>
|
Welcome,
|
||||||
|
<span className="text-strong-accent">{loaderData.user?.username}</span>
|
||||||
</span>
|
</span>
|
||||||
<Button variant="outline" size="sm" onClick={() => logout.mutate({})} loading={logout.isPending}>
|
<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>
|
</header>
|
||||||
<main className="flex flex-col pt-4 sm:pt-8 px-2 sm:px-4 pb-4 container mx-auto">
|
<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>
|
</GridBackground>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SidebarProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
18
apps/client/app/components/repository-icon.tsx
Normal file
18
apps/client/app/components/repository-icon.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
|
};
|
||||||
97
apps/client/app/components/snapshots-table.tsx
Normal file
97
apps/client/app/components/snapshots-table.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
36
apps/client/app/components/ui/badge.tsx
Normal file
36
apps/client/app/components/ui/badge.tsx
Normal 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 };
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
|
||||||
}
|
|
||||||
|
|||||||
30
apps/client/app/components/ui/checkbox.tsx
Normal file
30
apps/client/app/components/ui/checkbox.tsx
Normal 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 }
|
||||||
26
apps/client/app/components/ui/separator.tsx
Normal file
26
apps/client/app/components/ui/separator.tsx
Normal 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 }
|
||||||
139
apps/client/app/components/ui/sheet.tsx
Normal file
139
apps/client/app/components/ui/sheet.tsx
Normal 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,
|
||||||
|
}
|
||||||
677
apps/client/app/components/ui/sidebar.tsx
Normal file
677
apps/client/app/components/ui/sidebar.tsx
Normal 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,
|
||||||
|
};
|
||||||
13
apps/client/app/components/ui/skeleton.tsx
Normal file
13
apps/client/app/components/ui/skeleton.tsx
Normal 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 }
|
||||||
165
apps/client/app/components/volume-file-browser.tsx
Normal file
165
apps/client/app/components/volume-file-browser.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
19
apps/client/app/hooks/use-mobile.ts
Normal file
19
apps/client/app/hooks/use-mobile.ts
Normal 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
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|||||||
@@ -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("/");
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
100
apps/client/app/modules/auth/routes/login.tsx
Normal file
100
apps/client/app/modules/auth/routes/login.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
127
apps/client/app/modules/auth/routes/onboarding.tsx
Normal file
127
apps/client/app/modules/auth/routes/onboarding.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
152
apps/client/app/modules/backups/components/schedule-summary.tsx
Normal file
152
apps/client/app/modules/backups/components/schedule-summary.tsx
Normal 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
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
196
apps/client/app/modules/backups/routes/backup-details.tsx
Normal file
196
apps/client/app/modules/backups/routes/backup-details.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
123
apps/client/app/modules/backups/routes/backups.tsx
Normal file
123
apps/client/app/modules/backups/routes/backups.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
185
apps/client/app/modules/backups/routes/create-backup.tsx
Normal file
185
apps/client/app/modules/backups/routes/create-backup.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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'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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
189
apps/client/app/modules/repositories/routes/repositories.tsx
Normal file
189
apps/client/app/modules/repositories/routes/repositories.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
62
apps/client/app/modules/repositories/tabs/info.tsx
Normal file
62
apps/client/app/modules/repositories/tabs/info.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
174
apps/client/app/modules/repositories/tabs/snapshots.tsx
Normal file
174
apps/client/app/modules/repositories/tabs/snapshots.tsx
Normal 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:
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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) => {
|
||||||
@@ -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) {
|
||||||
@@ -125,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-4">
|
<Tabs value={activeTab} onValueChange={(value) => setSearchParams({ tab: value })} className="mt-4">
|
||||||
<TabsList className="mb-2">
|
<TabsList className="mb-2">
|
||||||
<TabsTrigger value="info">Configuration</TabsTrigger>
|
<TabsTrigger value="info">Configuration</TabsTrigger>
|
||||||
|
<TabsTrigger 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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
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";
|
||||||
@@ -13,7 +13,7 @@ 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, 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 [
|
||||||
@@ -27,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("");
|
||||||
@@ -53,21 +53,24 @@ 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?.volumes.length === 0;
|
const hasNoVolumes = data.length === 0;
|
||||||
const hasNoFilteredVolumes = filteredVolumes.length === 0 && !hasNoVolumes;
|
const hasNoFilteredVolumes = filteredVolumes.length === 0 && !hasNoVolumes;
|
||||||
|
|
||||||
if (hasNoVolumes) {
|
if (hasNoVolumes) {
|
||||||
return (
|
return (
|
||||||
<Card className="p-0 gap-0">
|
<EmptyState
|
||||||
<EmptyState />
|
icon={HardDrive}
|
||||||
</Card>
|
title="No volume"
|
||||||
|
description="Manage and monitor all your storage backends in one place with advanced features like automatic mounting and health checks."
|
||||||
|
button={<CreateVolumeDialog open={createVolumeOpen} setOpen={setCreateVolumeOpen} />}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,7 +119,6 @@ export default function Home({ loaderData }: Route.ComponentProps) {
|
|||||||
<TableRow>
|
<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>
|
||||||
@@ -144,14 +146,6 @@ export default function Home({ loaderData }: Route.ComponentProps) {
|
|||||||
<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>
|
||||||
@@ -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) {
|
||||||
41
apps/client/app/modules/volumes/tabs/files.tsx
Normal file
41
apps/client/app/modules/volumes/tabs/files.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
};
|
};
|
||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -1,103 +0,0 @@
|
|||||||
import { arktypeResolver } from "@hookform/resolvers/arktype";
|
|
||||||
import { useMutation } from "@tanstack/react-query";
|
|
||||||
import { type } from "arktype";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import { useNavigate } from "react-router";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { loginMutation } from "~/api-client/@tanstack/react-query.gen";
|
|
||||||
import { GridBackground } from "~/components/grid-background";
|
|
||||||
import { Button } from "~/components/ui/button";
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
|
|
||||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "~/components/ui/form";
|
|
||||||
import { Input } from "~/components/ui/input";
|
|
||||||
|
|
||||||
const loginSchema = type({
|
|
||||||
username: "2<=string<=50",
|
|
||||||
password: "string>=1",
|
|
||||||
});
|
|
||||||
|
|
||||||
type LoginFormValues = typeof loginSchema.inferIn;
|
|
||||||
|
|
||||||
export default function LoginPage() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const form = useForm<LoginFormValues>({
|
|
||||||
resolver: arktypeResolver(loginSchema),
|
|
||||||
defaultValues: {
|
|
||||||
username: "",
|
|
||||||
password: "",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const login = useMutation({
|
|
||||||
...loginMutation(),
|
|
||||||
onSuccess: async () => {
|
|
||||||
navigate("/");
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
console.error(error);
|
|
||||||
toast.error("Login failed");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const onSubmit = (values: LoginFormValues) => {
|
|
||||||
login.mutate({
|
|
||||||
body: {
|
|
||||||
username: values.username.trim(),
|
|
||||||
password: values.password.trim(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<GridBackground className="flex items-center justify-center p-4">
|
|
||||||
<Card className="w-full max-w-md">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-2xl font-bold">Welcome Back</CardTitle>
|
|
||||||
<CardDescription>Sign in to your account</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<Form {...form}>
|
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="username"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Username</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
{...field}
|
|
||||||
type="text"
|
|
||||||
placeholder="Enter your username"
|
|
||||||
disabled={login.isPending}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="password"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Password</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input {...field} type="password" placeholder="Enter your password" disabled={login.isPending} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<Button type="submit" className="w-full" loading={login.isPending}>
|
|
||||||
Sign In
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</GridBackground>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
import { arktypeResolver } from "@hookform/resolvers/arktype";
|
|
||||||
import { useMutation } from "@tanstack/react-query";
|
|
||||||
import { type } from "arktype";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import { useNavigate } from "react-router";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { registerMutation } from "~/api-client/@tanstack/react-query.gen";
|
|
||||||
import { GridBackground } from "~/components/grid-background";
|
|
||||||
import { Button } from "~/components/ui/button";
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
|
|
||||||
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "~/components/ui/form";
|
|
||||||
import { Input } from "~/components/ui/input";
|
|
||||||
|
|
||||||
const onboardingSchema = type({
|
|
||||||
username: "2<=string<=50",
|
|
||||||
password: "string>=8",
|
|
||||||
confirmPassword: "string>=1",
|
|
||||||
});
|
|
||||||
|
|
||||||
type OnboardingFormValues = typeof onboardingSchema.inferIn;
|
|
||||||
|
|
||||||
export default function OnboardingPage() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const form = useForm<OnboardingFormValues>({
|
|
||||||
resolver: arktypeResolver(onboardingSchema),
|
|
||||||
defaultValues: {
|
|
||||||
username: "",
|
|
||||||
password: "",
|
|
||||||
confirmPassword: "",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const registerUser = useMutation({
|
|
||||||
...registerMutation(),
|
|
||||||
onSuccess: async () => {
|
|
||||||
toast.success("Admin user created successfully!");
|
|
||||||
navigate("/");
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
console.error(error);
|
|
||||||
toast.error("Failed to create admin user");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const onSubmit = (values: OnboardingFormValues) => {
|
|
||||||
if (values.password !== values.confirmPassword) {
|
|
||||||
form.setError("confirmPassword", {
|
|
||||||
type: "manual",
|
|
||||||
message: "Passwords do not match",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
registerUser.mutate({
|
|
||||||
body: {
|
|
||||||
username: values.username.trim(),
|
|
||||||
password: values.password.trim(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<GridBackground className="flex items-center justify-center p-4">
|
|
||||||
<Card className="w-full max-w-md">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-2xl font-bold">Welcome to Ironmount</CardTitle>
|
|
||||||
<CardDescription>Create the admin user to get started</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<Form {...form}>
|
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="username"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Username</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input {...field} type="text" placeholder="admin" disabled={registerUser.isPending} autoFocus />
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>Choose a username for the admin account</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="password"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Password</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
{...field}
|
|
||||||
type="password"
|
|
||||||
placeholder="Enter a secure password"
|
|
||||||
disabled={registerUser.isPending}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>Password must be at least 8 characters long.</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="confirmPassword"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Confirm Password</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
{...field}
|
|
||||||
type="password"
|
|
||||||
placeholder="Re-enter your password"
|
|
||||||
disabled={registerUser.isPending}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<Button type="submit" className="w-full" loading={registerUser.isPending}>
|
|
||||||
Create Admin User
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</GridBackground>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
5
apps/client/app/routes/root.tsx
Normal file
5
apps/client/app/routes/root.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from "react-router";
|
||||||
|
|
||||||
|
export const clientLoader = async () => {
|
||||||
|
return redirect("/volumes");
|
||||||
|
};
|
||||||
14
apps/client/app/utils/object.ts
Normal file
14
apps/client/app/utils/object.ts
Normal 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;
|
||||||
|
}
|
||||||
17
apps/client/app/utils/utils.ts
Normal file
17
apps/client/app/utils/utils.ts
Normal 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"}`;
|
||||||
|
};
|
||||||
@@ -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,12 +27,14 @@
|
|||||||
"@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",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
|
|||||||
BIN
apps/client/public/background.jpg
Normal file
BIN
apps/client/public/background.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 90 KiB |
15
apps/server/drizzle/0006_secret_micromacro.sql
Normal file
15
apps/server/drizzle/0006_secret_micromacro.sql
Normal 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`;
|
||||||
1
apps/server/drizzle/0007_watery_sersi.sql
Normal file
1
apps/server/drizzle/0007_watery_sersi.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `repositories_table` RENAME COLUMN "backend" TO "type";
|
||||||
20
apps/server/drizzle/0008_silent_lady_bullseye.sql
Normal file
20
apps/server/drizzle/0008_silent_lady_bullseye.sql
Normal 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`);
|
||||||
1
apps/server/drizzle/0009_little_adam_warlock.sql
Normal file
1
apps/server/drizzle/0009_little_adam_warlock.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
DROP INDEX `backup_schedules_table_volume_id_unique`;
|
||||||
311
apps/server/drizzle/meta/0006_snapshot.json
Normal file
311
apps/server/drizzle/meta/0006_snapshot.json
Normal 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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
313
apps/server/drizzle/meta/0007_snapshot.json
Normal file
313
apps/server/drizzle/meta/0007_snapshot.json
Normal 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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
459
apps/server/drizzle/meta/0008_snapshot.json
Normal file
459
apps/server/drizzle/meta/0008_snapshot.json
Normal 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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
451
apps/server/drizzle/meta/0009_snapshot.json
Normal file
451
apps/server/drizzle/meta/0009_snapshot.json
Normal 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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
44
apps/server/src/core/scheduler.ts
Normal file
44
apps/server/src/core/scheduler.ts
Normal 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();
|
||||||
@@ -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 });
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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" }));
|
||||||
|
|
||||||
|
|||||||
29
apps/server/src/jobs/backup-execution.ts
Normal file
29
apps/server/src/jobs/backup-execution.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
49
apps/server/src/jobs/cleanup-dangling.ts
Normal file
49
apps/server/src/jobs/cleanup-dangling.ts
Normal 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() };
|
||||||
|
}
|
||||||
|
}
|
||||||
10
apps/server/src/jobs/cleanup-sessions.ts
Normal file
10
apps/server/src/jobs/cleanup-sessions.ts
Normal 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() };
|
||||||
|
}
|
||||||
|
}
|
||||||
25
apps/server/src/jobs/healthchecks.ts
Normal file
25
apps/server/src/jobs/healthchecks.ts
Normal 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() };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { BackendStatus } from "@ironmount/schemas";
|
import type { BackendStatus } from "@ironmount/schemas";
|
||||||
import { VOLUME_MOUNT_BASE } from "../../core/constants";
|
|
||||||
import type { Volume } from "../../db/schema";
|
import type { Volume } from "../../db/schema";
|
||||||
|
import { getVolumePath } from "../volumes/helpers";
|
||||||
import { makeDirectoryBackend } from "./directory/directory-backend";
|
import { makeDirectoryBackend } from "./directory/directory-backend";
|
||||||
import { makeNfsBackend } from "./nfs/nfs-backend";
|
import { makeNfsBackend } from "./nfs/nfs-backend";
|
||||||
import { makeSmbBackend } from "./smb/smb-backend";
|
import { makeSmbBackend } from "./smb/smb-backend";
|
||||||
@@ -18,7 +18,7 @@ export type VolumeBackend = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const createVolumeBackend = (volume: Volume): VolumeBackend => {
|
export const createVolumeBackend = (volume: Volume): VolumeBackend => {
|
||||||
const path = `${VOLUME_MOUNT_BASE}/${volume.name}/_data`;
|
const path = getVolumePath(volume.name);
|
||||||
|
|
||||||
switch (volume.config.backend) {
|
switch (volume.config.backend) {
|
||||||
case "nfs": {
|
case "nfs": {
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import * as fs from "node:fs/promises";
|
import * as fs from "node:fs/promises";
|
||||||
import * as npath from "node:path";
|
import * as npath from "node:path";
|
||||||
import { BACKEND_STATUS, type BackendConfig } from "@ironmount/schemas";
|
import { BACKEND_STATUS, type BackendConfig } from "@ironmount/schemas";
|
||||||
import type { VolumeBackend } from "../backend";
|
|
||||||
import { logger } from "../../../utils/logger";
|
|
||||||
import { toMessage } from "../../../utils/errors";
|
import { toMessage } from "../../../utils/errors";
|
||||||
|
import { logger } from "../../../utils/logger";
|
||||||
|
import type { VolumeBackend } from "../backend";
|
||||||
|
|
||||||
const mount = async (_config: BackendConfig, path: string) => {
|
const mount = async (_config: BackendConfig, path: string) => {
|
||||||
logger.info("Mounting directory volume...");
|
logger.info("Mounting directory volume...", path);
|
||||||
await fs.mkdir(path, { recursive: true });
|
await fs.mkdir(path, { recursive: true });
|
||||||
return { status: BACKEND_STATUS.mounted };
|
return { status: BACKEND_STATUS.mounted };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import * as fs from "node:fs/promises";
|
import * as fs from "node:fs/promises";
|
||||||
import * as os from "node:os";
|
import * as os from "node:os";
|
||||||
import { BACKEND_STATUS, type BackendConfig } from "@ironmount/schemas";
|
import { BACKEND_STATUS, type BackendConfig } from "@ironmount/schemas";
|
||||||
import type { VolumeBackend } from "../backend";
|
|
||||||
import { logger } from "../../../utils/logger";
|
|
||||||
import { withTimeout } from "../../../utils/timeout";
|
|
||||||
import { OPERATION_TIMEOUT } from "../../../core/constants";
|
import { OPERATION_TIMEOUT } from "../../../core/constants";
|
||||||
import { toMessage } from "../../../utils/errors";
|
import { toMessage } from "../../../utils/errors";
|
||||||
|
import { logger } from "../../../utils/logger";
|
||||||
import { getMountForPath } from "../../../utils/mountinfo";
|
import { getMountForPath } from "../../../utils/mountinfo";
|
||||||
|
import { withTimeout } from "../../../utils/timeout";
|
||||||
|
import type { VolumeBackend } from "../backend";
|
||||||
import { createTestFile, executeMount, executeUnmount } from "../utils/backend-utils";
|
import { createTestFile, executeMount, executeUnmount } from "../utils/backend-utils";
|
||||||
|
|
||||||
const mount = async (config: BackendConfig, path: string) => {
|
const mount = async (config: BackendConfig, path: string) => {
|
||||||
|
|||||||
@@ -1,17 +1,31 @@
|
|||||||
import * as npath from "node:path";
|
|
||||||
import * as fs from "node:fs/promises";
|
|
||||||
import { execFile as execFileCb } from "node:child_process";
|
import { execFile as execFileCb } from "node:child_process";
|
||||||
|
import * as fs from "node:fs/promises";
|
||||||
|
import * as npath from "node:path";
|
||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
import { OPERATION_TIMEOUT } from "../../../core/constants";
|
import { OPERATION_TIMEOUT } from "../../../core/constants";
|
||||||
|
import { toMessage } from "../../../utils/errors";
|
||||||
import { logger } from "../../../utils/logger";
|
import { logger } from "../../../utils/logger";
|
||||||
|
import { access, constants } from "node:fs/promises";
|
||||||
|
|
||||||
const execFile = promisify(execFileCb);
|
const execFile = promisify(execFileCb);
|
||||||
|
|
||||||
export const executeMount = async (args: string[]): Promise<void> => {
|
export const executeMount = async (args: string[]): Promise<void> => {
|
||||||
const { stderr } = await execFile("mount", args, {
|
let stderr: string | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await access("/host/proc", constants.F_OK);
|
||||||
|
const result = await execFile("nsenter", ["--mount=/host/proc/1/ns/mnt", "mount", ...args], {
|
||||||
timeout: OPERATION_TIMEOUT,
|
timeout: OPERATION_TIMEOUT,
|
||||||
maxBuffer: 1024 * 1024,
|
maxBuffer: 1024 * 1024,
|
||||||
});
|
});
|
||||||
|
stderr = result.stderr;
|
||||||
|
} catch (_) {
|
||||||
|
const result = await execFile("mount", args, {
|
||||||
|
timeout: OPERATION_TIMEOUT,
|
||||||
|
maxBuffer: 1024 * 1024,
|
||||||
|
});
|
||||||
|
stderr = result.stderr;
|
||||||
|
}
|
||||||
|
|
||||||
if (stderr?.trim()) {
|
if (stderr?.trim()) {
|
||||||
logger.warn(stderr.trim());
|
logger.warn(stderr.trim());
|
||||||
@@ -19,10 +33,22 @@ export const executeMount = async (args: string[]): Promise<void> => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const executeUnmount = async (path: string): Promise<void> => {
|
export const executeUnmount = async (path: string): Promise<void> => {
|
||||||
const { stderr } = await execFile("umount", ["-l", "-f", path], {
|
let stderr: string | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await access("/host/proc", constants.F_OK);
|
||||||
|
const result = await execFile("nsenter", ["--mount=/host/proc/1/ns/mnt", "umount", "-l", "-f", path], {
|
||||||
timeout: OPERATION_TIMEOUT,
|
timeout: OPERATION_TIMEOUT,
|
||||||
maxBuffer: 1024 * 1024,
|
maxBuffer: 1024 * 1024,
|
||||||
});
|
});
|
||||||
|
stderr = result.stderr;
|
||||||
|
} catch (_) {
|
||||||
|
const result = await execFile("umount", ["-l", "-f", path], {
|
||||||
|
timeout: OPERATION_TIMEOUT,
|
||||||
|
maxBuffer: 1024 * 1024,
|
||||||
|
});
|
||||||
|
stderr = result.stderr;
|
||||||
|
}
|
||||||
|
|
||||||
if (stderr?.trim()) {
|
if (stderr?.trim()) {
|
||||||
logger.warn(stderr.trim());
|
logger.warn(stderr.trim());
|
||||||
@@ -33,5 +59,18 @@ export const createTestFile = async (path: string): Promise<void> => {
|
|||||||
const testFilePath = npath.join(path, `.healthcheck-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
|
const testFilePath = npath.join(path, `.healthcheck-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
|
||||||
|
|
||||||
await fs.writeFile(testFilePath, "healthcheck");
|
await fs.writeFile(testFilePath, "healthcheck");
|
||||||
await fs.unlink(testFilePath);
|
|
||||||
|
const files = await fs.readdir(path);
|
||||||
|
await Promise.all(
|
||||||
|
files.map(async (file) => {
|
||||||
|
if (file.startsWith(".healthcheck-")) {
|
||||||
|
const filePath = npath.join(path, file);
|
||||||
|
try {
|
||||||
|
await fs.unlink(filePath);
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(`Failed to stat or unlink file ${filePath}: ${toMessage(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -139,8 +139,6 @@ const checkHealth = async (path: string) => {
|
|||||||
|
|
||||||
const mount = await getMountForPath(path);
|
const mount = await getMountForPath(path);
|
||||||
|
|
||||||
console.log(mount);
|
|
||||||
|
|
||||||
if (!mount || mount.fstype !== "fuse") {
|
if (!mount || mount.fstype !== "fuse") {
|
||||||
throw new Error(`Path ${path} is not mounted as WebDAV.`);
|
throw new Error(`Path ${path} is not mounted as WebDAV.`);
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user