mirror of
https://github.com/nicotsx/ironmount.git
synced 2025-12-10 12:10:51 +01:00
Compare commits
29 Commits
v0.0.1
...
feat/resti
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae592481af | ||
|
|
65a7f436fe | ||
|
|
8af0bac63b | ||
|
|
41756e087a | ||
|
|
71ca5d3309 | ||
|
|
e29908757f | ||
|
|
15f0dc637d | ||
|
|
d16be6cbca | ||
|
|
1e3419c250 | ||
|
|
a5e0fb6aa2 | ||
|
|
3d87814aee | ||
|
|
56a4afdc92 | ||
|
|
728cfebeb7 | ||
|
|
472f7799a4 | ||
|
|
e134d0e1d1 | ||
|
|
689f14dff7 | ||
|
|
8d46074bb1 | ||
|
|
5f003fe69d | ||
|
|
7784389b57 | ||
|
|
1ad8f69355 | ||
|
|
2be7e18ab5 | ||
|
|
0120641e3a | ||
|
|
689e92ffc1 | ||
|
|
86adda848e | ||
|
|
c013351026 | ||
|
|
1e7530cc09 | ||
|
|
7f79fd7628 | ||
|
|
c29f35fc34 | ||
|
|
9872185b69 |
@@ -1,6 +1,8 @@
|
|||||||
*
|
*
|
||||||
|
|
||||||
!turbo.json
|
!turbo.json
|
||||||
|
!bun.lock
|
||||||
|
!package.json
|
||||||
|
|
||||||
!**/package.json
|
!**/package.json
|
||||||
!**/bun.lock
|
!**/bun.lock
|
||||||
@@ -8,6 +10,7 @@
|
|||||||
!**/vite.config.ts
|
!**/vite.config.ts
|
||||||
!**/react-router.config.ts
|
!**/react-router.config.ts
|
||||||
!**/build.ts
|
!**/build.ts
|
||||||
|
!**/components.json
|
||||||
|
|
||||||
!apps/**/src/**
|
!apps/**/src/**
|
||||||
!apps/**/drizzle/**
|
!apps/**/drizzle/**
|
||||||
|
|||||||
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-*
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
ARG BUN_VERSION="1.2.20"
|
ARG BUN_VERSION="1.3.0"
|
||||||
|
|
||||||
FROM oven/bun:${BUN_VERSION}-alpine AS runner_base
|
FROM oven/bun:${BUN_VERSION}-alpine AS runner_base
|
||||||
|
|
||||||
RUN apk add --no-cache davfs2
|
RUN apk add --no-cache davfs2 restic
|
||||||
|
|
||||||
# ------------------------------
|
# ------------------------------
|
||||||
# DEVELOPMENT
|
# DEVELOPMENT
|
||||||
@@ -49,7 +49,9 @@ FROM runner_base AS production
|
|||||||
|
|
||||||
ENV NODE_ENV="production"
|
ENV NODE_ENV="production"
|
||||||
|
|
||||||
RUN apk add --no-cache davfs2
|
# RUN bun i ssh2
|
||||||
|
|
||||||
|
RUN apk add --no-cache davfs2=1.6.1-r2
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
22
README.md
22
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
|
||||||
|
|
||||||
@@ -23,9 +24,7 @@ Ironmount is an easy to use web interface to manage your remote storage and moun
|
|||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|
||||||
https://github.com/nicotsx/ironmount/blob/main/screenshots/volume-creation.png?raw=true
|
- ✅ Support for multiple protocols: NFS, SMB, WebDAV, Directory
|
||||||
|
|
||||||
- ✅ Support for multiple protocols: NFS, SMB, FTP, Directory
|
|
||||||
- 📡 Mount your remote storage as local folders
|
- 📡 Mount your remote storage as local folders
|
||||||
- 🐳 Docker integration: mount your remote storage directly into your containers via a docker volume syntax
|
- 🐳 Docker integration: mount your remote storage directly into your containers via a docker volume syntax
|
||||||
- 🔍 Keep an eye on your mounts with health checks and automatic remounting on error
|
- 🔍 Keep an eye on your mounts with health checks and automatic remounting on error
|
||||||
@@ -33,11 +32,8 @@ https://github.com/nicotsx/ironmount/blob/main/screenshots/volume-creation.png?r
|
|||||||
|
|
||||||
### Coming soon
|
### Coming soon
|
||||||
|
|
||||||
- 🔐 User authentication and role management
|
- Automated backups with encryption and retention policies
|
||||||
- 💾 Automated backups and snapshots with encryption, strategies and retention policies
|
- Integration with cloud storage providers (e.g. AWS S3, Google Drive, Dropbox)
|
||||||
- 🔄 Re-exporting your mounts to other protocols (e.g. mount an FTP server as an SMB share with fine-grained permissions)
|
|
||||||
- ☁️ Integration with cloud storage providers (e.g. AWS S3, Google Drive, Dropbox)
|
|
||||||
- 🔀 Storage sharding and replication for high availability and performance
|
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
@@ -46,7 +42,7 @@ In order to run Ironmount, you need to have Docker and Docker Compose installed
|
|||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
ironmount:
|
ironmount:
|
||||||
image: nicotsx/ironmount:v0.0.1
|
image: ghcr.io/nicotsx/ironmount:v0.2.0
|
||||||
container_name: ironmount
|
container_name: ironmount
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
cap_add:
|
cap_add:
|
||||||
@@ -69,7 +65,7 @@ volumes:
|
|||||||
Then, run the following command to start Ironmount:
|
Then, run the following command to start Ironmount:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker-compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
Once the container is running, you can access the web interface at `http://<your-server-ip>:4096`.
|
Once the container is running, you can access the web interface at `http://<your-server-ip>:4096`.
|
||||||
@@ -77,7 +73,3 @@ Once the container is running, you can access the web interface at `http://<your
|
|||||||
## Docker volume usage
|
## Docker volume usage
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Volume creation
|
|
||||||
|
|
||||||

|
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
FROM node:20-alpine AS development-dependencies-env
|
|
||||||
COPY . /app
|
|
||||||
WORKDIR /app
|
|
||||||
RUN npm ci
|
|
||||||
|
|
||||||
FROM node:20-alpine AS production-dependencies-env
|
|
||||||
COPY ./package.json package-lock.json /app/
|
|
||||||
WORKDIR /app
|
|
||||||
RUN npm ci --omit=dev
|
|
||||||
|
|
||||||
FROM node:20-alpine AS build-env
|
|
||||||
COPY . /app/
|
|
||||||
COPY --from=development-dependencies-env /app/node_modules /app/node_modules
|
|
||||||
WORKDIR /app
|
|
||||||
RUN npm run build
|
|
||||||
|
|
||||||
FROM node:20-alpine
|
|
||||||
COPY ./package.json package-lock.json /app/
|
|
||||||
COPY --from=production-dependencies-env /app/node_modules /app/node_modules
|
|
||||||
COPY --from=build-env /app/build /app/build
|
|
||||||
WORKDIR /app
|
|
||||||
CMD ["npm", "run", "start"]
|
|
||||||
@@ -2,6 +2,11 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
type Options,
|
type Options,
|
||||||
|
register,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
getMe,
|
||||||
|
getStatus,
|
||||||
listVolumes,
|
listVolumes,
|
||||||
createVolume,
|
createVolume,
|
||||||
testConnection,
|
testConnection,
|
||||||
@@ -12,9 +17,18 @@ import {
|
|||||||
mountVolume,
|
mountVolume,
|
||||||
unmountVolume,
|
unmountVolume,
|
||||||
healthCheckVolume,
|
healthCheckVolume,
|
||||||
|
listFiles,
|
||||||
} 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 {
|
||||||
|
RegisterData,
|
||||||
|
RegisterResponse,
|
||||||
|
LoginData,
|
||||||
|
LoginResponse,
|
||||||
|
LogoutData,
|
||||||
|
LogoutResponse,
|
||||||
|
GetMeData,
|
||||||
|
GetStatusData,
|
||||||
ListVolumesData,
|
ListVolumesData,
|
||||||
CreateVolumeData,
|
CreateVolumeData,
|
||||||
CreateVolumeResponse,
|
CreateVolumeResponse,
|
||||||
@@ -32,6 +46,7 @@ import type {
|
|||||||
UnmountVolumeResponse,
|
UnmountVolumeResponse,
|
||||||
HealthCheckVolumeData,
|
HealthCheckVolumeData,
|
||||||
HealthCheckVolumeResponse,
|
HealthCheckVolumeResponse,
|
||||||
|
ListFilesData,
|
||||||
} from "../types.gen";
|
} from "../types.gen";
|
||||||
import { client as _heyApiClient } from "../client.gen";
|
import { client as _heyApiClient } from "../client.gen";
|
||||||
|
|
||||||
@@ -74,6 +89,163 @@ const createQueryKey = <TOptions extends Options>(
|
|||||||
return [params];
|
return [params];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const registerQueryKey = (options?: Options<RegisterData>) => createQueryKey("register", options);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a new user
|
||||||
|
*/
|
||||||
|
export const registerOptions = (options?: Options<RegisterData>) => {
|
||||||
|
return queryOptions({
|
||||||
|
queryFn: async ({ queryKey, signal }) => {
|
||||||
|
const { data } = await register({
|
||||||
|
...options,
|
||||||
|
...queryKey[0],
|
||||||
|
signal,
|
||||||
|
throwOnError: true,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
queryKey: registerQueryKey(options),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a new user
|
||||||
|
*/
|
||||||
|
export const registerMutation = (
|
||||||
|
options?: Partial<Options<RegisterData>>,
|
||||||
|
): UseMutationOptions<RegisterResponse, DefaultError, Options<RegisterData>> => {
|
||||||
|
const mutationOptions: UseMutationOptions<RegisterResponse, DefaultError, Options<RegisterData>> = {
|
||||||
|
mutationFn: async (localOptions) => {
|
||||||
|
const { data } = await register({
|
||||||
|
...options,
|
||||||
|
...localOptions,
|
||||||
|
throwOnError: true,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return mutationOptions;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const loginQueryKey = (options?: Options<LoginData>) => createQueryKey("login", options);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Login with username and password
|
||||||
|
*/
|
||||||
|
export const loginOptions = (options?: Options<LoginData>) => {
|
||||||
|
return queryOptions({
|
||||||
|
queryFn: async ({ queryKey, signal }) => {
|
||||||
|
const { data } = await login({
|
||||||
|
...options,
|
||||||
|
...queryKey[0],
|
||||||
|
signal,
|
||||||
|
throwOnError: true,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
queryKey: loginQueryKey(options),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Login with username and password
|
||||||
|
*/
|
||||||
|
export const loginMutation = (
|
||||||
|
options?: Partial<Options<LoginData>>,
|
||||||
|
): UseMutationOptions<LoginResponse, DefaultError, Options<LoginData>> => {
|
||||||
|
const mutationOptions: UseMutationOptions<LoginResponse, DefaultError, Options<LoginData>> = {
|
||||||
|
mutationFn: async (localOptions) => {
|
||||||
|
const { data } = await login({
|
||||||
|
...options,
|
||||||
|
...localOptions,
|
||||||
|
throwOnError: true,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return mutationOptions;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const logoutQueryKey = (options?: Options<LogoutData>) => createQueryKey("logout", options);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logout current user
|
||||||
|
*/
|
||||||
|
export const logoutOptions = (options?: Options<LogoutData>) => {
|
||||||
|
return queryOptions({
|
||||||
|
queryFn: async ({ queryKey, signal }) => {
|
||||||
|
const { data } = await logout({
|
||||||
|
...options,
|
||||||
|
...queryKey[0],
|
||||||
|
signal,
|
||||||
|
throwOnError: true,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
queryKey: logoutQueryKey(options),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logout current user
|
||||||
|
*/
|
||||||
|
export const logoutMutation = (
|
||||||
|
options?: Partial<Options<LogoutData>>,
|
||||||
|
): UseMutationOptions<LogoutResponse, DefaultError, Options<LogoutData>> => {
|
||||||
|
const mutationOptions: UseMutationOptions<LogoutResponse, DefaultError, Options<LogoutData>> = {
|
||||||
|
mutationFn: async (localOptions) => {
|
||||||
|
const { data } = await logout({
|
||||||
|
...options,
|
||||||
|
...localOptions,
|
||||||
|
throwOnError: true,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return mutationOptions;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getMeQueryKey = (options?: Options<GetMeData>) => createQueryKey("getMe", options);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current authenticated user
|
||||||
|
*/
|
||||||
|
export const getMeOptions = (options?: Options<GetMeData>) => {
|
||||||
|
return queryOptions({
|
||||||
|
queryFn: async ({ queryKey, signal }) => {
|
||||||
|
const { data } = await getMe({
|
||||||
|
...options,
|
||||||
|
...queryKey[0],
|
||||||
|
signal,
|
||||||
|
throwOnError: true,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
queryKey: getMeQueryKey(options),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getStatusQueryKey = (options?: Options<GetStatusData>) => createQueryKey("getStatus", options);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get authentication system status
|
||||||
|
*/
|
||||||
|
export const getStatusOptions = (options?: Options<GetStatusData>) => {
|
||||||
|
return queryOptions({
|
||||||
|
queryFn: async ({ queryKey, signal }) => {
|
||||||
|
const { data } = await getStatus({
|
||||||
|
...options,
|
||||||
|
...queryKey[0],
|
||||||
|
signal,
|
||||||
|
throwOnError: true,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
queryKey: getStatusQueryKey(options),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export const listVolumesQueryKey = (options?: Options<ListVolumesData>) => createQueryKey("listVolumes", options);
|
export const listVolumesQueryKey = (options?: Options<ListVolumesData>) => createQueryKey("listVolumes", options);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -369,3 +541,23 @@ 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),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@@ -2,6 +2,19 @@
|
|||||||
|
|
||||||
import type { Options as ClientOptions, TDataShape, Client } from "./client";
|
import type { Options as ClientOptions, TDataShape, Client } from "./client";
|
||||||
import type {
|
import type {
|
||||||
|
RegisterData,
|
||||||
|
RegisterResponses,
|
||||||
|
RegisterErrors,
|
||||||
|
LoginData,
|
||||||
|
LoginResponses,
|
||||||
|
LoginErrors,
|
||||||
|
LogoutData,
|
||||||
|
LogoutResponses,
|
||||||
|
GetMeData,
|
||||||
|
GetMeResponses,
|
||||||
|
GetMeErrors,
|
||||||
|
GetStatusData,
|
||||||
|
GetStatusResponses,
|
||||||
ListVolumesData,
|
ListVolumesData,
|
||||||
ListVolumesResponses,
|
ListVolumesResponses,
|
||||||
CreateVolumeData,
|
CreateVolumeData,
|
||||||
@@ -28,6 +41,9 @@ import type {
|
|||||||
HealthCheckVolumeData,
|
HealthCheckVolumeData,
|
||||||
HealthCheckVolumeResponses,
|
HealthCheckVolumeResponses,
|
||||||
HealthCheckVolumeErrors,
|
HealthCheckVolumeErrors,
|
||||||
|
ListFilesData,
|
||||||
|
ListFilesResponses,
|
||||||
|
ListFilesErrors,
|
||||||
} from "./types.gen";
|
} from "./types.gen";
|
||||||
import { client as _heyApiClient } from "./client.gen";
|
import { client as _heyApiClient } from "./client.gen";
|
||||||
|
|
||||||
@@ -48,6 +64,64 @@ export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends
|
|||||||
meta?: Record<string, unknown>;
|
meta?: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a new user
|
||||||
|
*/
|
||||||
|
export const register = <ThrowOnError extends boolean = false>(options?: Options<RegisterData, ThrowOnError>) => {
|
||||||
|
return (options?.client ?? _heyApiClient).post<RegisterResponses, RegisterErrors, ThrowOnError>({
|
||||||
|
url: "/api/v1/auth/register",
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...options?.headers,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Login with username and password
|
||||||
|
*/
|
||||||
|
export const login = <ThrowOnError extends boolean = false>(options?: Options<LoginData, ThrowOnError>) => {
|
||||||
|
return (options?.client ?? _heyApiClient).post<LoginResponses, LoginErrors, ThrowOnError>({
|
||||||
|
url: "/api/v1/auth/login",
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...options?.headers,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logout current user
|
||||||
|
*/
|
||||||
|
export const logout = <ThrowOnError extends boolean = false>(options?: Options<LogoutData, ThrowOnError>) => {
|
||||||
|
return (options?.client ?? _heyApiClient).post<LogoutResponses, unknown, ThrowOnError>({
|
||||||
|
url: "/api/v1/auth/logout",
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current authenticated user
|
||||||
|
*/
|
||||||
|
export const getMe = <ThrowOnError extends boolean = false>(options?: Options<GetMeData, ThrowOnError>) => {
|
||||||
|
return (options?.client ?? _heyApiClient).get<GetMeResponses, GetMeErrors, ThrowOnError>({
|
||||||
|
url: "/api/v1/auth/me",
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get authentication system status
|
||||||
|
*/
|
||||||
|
export const getStatus = <ThrowOnError extends boolean = false>(options?: Options<GetStatusData, ThrowOnError>) => {
|
||||||
|
return (options?.client ?? _heyApiClient).get<GetStatusResponses, unknown, ThrowOnError>({
|
||||||
|
url: "/api/v1/auth/status",
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List all volumes
|
* List all volumes
|
||||||
*/
|
*/
|
||||||
@@ -177,3 +251,13 @@ 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, ListFilesErrors, ThrowOnError>({
|
||||||
|
url: "/api/v1/volumes/{name}/files",
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,5 +1,134 @@
|
|||||||
// This file is auto-generated by @hey-api/openapi-ts
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
|
export type RegisterData = {
|
||||||
|
body?: {
|
||||||
|
password: string;
|
||||||
|
username: string;
|
||||||
|
};
|
||||||
|
path?: never;
|
||||||
|
query?: never;
|
||||||
|
url: "/api/v1/auth/register";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RegisterErrors = {
|
||||||
|
/**
|
||||||
|
* Invalid request or username already exists
|
||||||
|
*/
|
||||||
|
400: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RegisterResponses = {
|
||||||
|
/**
|
||||||
|
* User created successfully
|
||||||
|
*/
|
||||||
|
201: {
|
||||||
|
message: string;
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RegisterResponse = RegisterResponses[keyof RegisterResponses];
|
||||||
|
|
||||||
|
export type LoginData = {
|
||||||
|
body?: {
|
||||||
|
password: string;
|
||||||
|
username: string;
|
||||||
|
};
|
||||||
|
path?: never;
|
||||||
|
query?: never;
|
||||||
|
url: "/api/v1/auth/login";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LoginErrors = {
|
||||||
|
/**
|
||||||
|
* Invalid credentials
|
||||||
|
*/
|
||||||
|
401: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LoginResponses = {
|
||||||
|
/**
|
||||||
|
* Login successful
|
||||||
|
*/
|
||||||
|
200: {
|
||||||
|
message: string;
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LoginResponse = LoginResponses[keyof LoginResponses];
|
||||||
|
|
||||||
|
export type LogoutData = {
|
||||||
|
body?: never;
|
||||||
|
path?: never;
|
||||||
|
query?: never;
|
||||||
|
url: "/api/v1/auth/logout";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LogoutResponses = {
|
||||||
|
/**
|
||||||
|
* Logout successful
|
||||||
|
*/
|
||||||
|
200: {
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LogoutResponse = LogoutResponses[keyof LogoutResponses];
|
||||||
|
|
||||||
|
export type GetMeData = {
|
||||||
|
body?: never;
|
||||||
|
path?: never;
|
||||||
|
query?: never;
|
||||||
|
url: "/api/v1/auth/me";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetMeErrors = {
|
||||||
|
/**
|
||||||
|
* Not authenticated
|
||||||
|
*/
|
||||||
|
401: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetMeResponses = {
|
||||||
|
/**
|
||||||
|
* Current user information
|
||||||
|
*/
|
||||||
|
200: {
|
||||||
|
message: string;
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetMeResponse = GetMeResponses[keyof GetMeResponses];
|
||||||
|
|
||||||
|
export type GetStatusData = {
|
||||||
|
body?: never;
|
||||||
|
path?: never;
|
||||||
|
query?: never;
|
||||||
|
url: "/api/v1/auth/status";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetStatusResponses = {
|
||||||
|
/**
|
||||||
|
* Authentication system status
|
||||||
|
*/
|
||||||
|
200: {
|
||||||
|
hasUsers: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetStatusResponse = GetStatusResponses[keyof GetStatusResponses];
|
||||||
|
|
||||||
export type ListVolumesData = {
|
export type ListVolumesData = {
|
||||||
body?: never;
|
body?: never;
|
||||||
path?: never;
|
path?: never;
|
||||||
@@ -471,6 +600,45 @@ export type HealthCheckVolumeResponses = {
|
|||||||
|
|
||||||
export type HealthCheckVolumeResponse = HealthCheckVolumeResponses[keyof HealthCheckVolumeResponses];
|
export type HealthCheckVolumeResponse = HealthCheckVolumeResponses[keyof HealthCheckVolumeResponses];
|
||||||
|
|
||||||
|
export type ListFilesData = {
|
||||||
|
body?: never;
|
||||||
|
path: {
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
query?: {
|
||||||
|
/**
|
||||||
|
* Subdirectory path to list (relative to volume root)
|
||||||
|
*/
|
||||||
|
path?: string;
|
||||||
|
};
|
||||||
|
url: "/api/v1/volumes/{name}/files";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ListFilesErrors = {
|
||||||
|
/**
|
||||||
|
* Volume not found
|
||||||
|
*/
|
||||||
|
404: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
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 ClientOptions = {
|
export type ClientOptions = {
|
||||||
baseUrl: "http://localhost:4096" | (string & {});
|
baseUrl: "http://localhost:4096" | (string & {});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,7 +11,10 @@
|
|||||||
|
|
||||||
html,
|
html,
|
||||||
body {
|
body {
|
||||||
@apply bg-white dark:bg-[#0D0D0D];
|
@apply bg-white dark:bg-[#131313];
|
||||||
|
overflow-x: hidden;
|
||||||
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
@@ -27,6 +30,7 @@ body {
|
|||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
--color-card: var(--card);
|
--color-card: var(--card);
|
||||||
--color-card-foreground: var(--card-foreground);
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-card-header: var(--card-header);
|
||||||
--color-popover: var(--popover);
|
--color-popover: var(--popover);
|
||||||
--color-popover-foreground: var(--popover-foreground);
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
--color-primary: var(--primary);
|
--color-primary: var(--primary);
|
||||||
@@ -54,6 +58,7 @@ body {
|
|||||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
--color-sidebar-border: var(--sidebar-border);
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
--color-sidebar-ring: var(--sidebar-ring);
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
--color-strong-accent: var(--strong-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
@@ -62,6 +67,7 @@ body {
|
|||||||
--foreground: oklch(0.145 0 0);
|
--foreground: oklch(0.145 0 0);
|
||||||
--card: oklch(1 0 0);
|
--card: oklch(1 0 0);
|
||||||
--card-foreground: oklch(0.145 0 0);
|
--card-foreground: oklch(0.145 0 0);
|
||||||
|
--card-header: oklch(0.922 0 0);
|
||||||
--popover: oklch(1 0 0);
|
--popover: oklch(1 0 0);
|
||||||
--popover-foreground: oklch(0.145 0 0);
|
--popover-foreground: oklch(0.145 0 0);
|
||||||
--primary: oklch(0.205 0 0);
|
--primary: oklch(0.205 0 0);
|
||||||
@@ -89,12 +95,14 @@ body {
|
|||||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||||
--sidebar-border: oklch(0.922 0 0);
|
--sidebar-border: oklch(0.922 0 0);
|
||||||
--sidebar-ring: oklch(0.708 0 0);
|
--sidebar-ring: oklch(0.708 0 0);
|
||||||
|
--strong-accent: #ff543a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--background: oklch(0.145 0 0);
|
--background: #131313;
|
||||||
--foreground: oklch(0.985 0 0);
|
--foreground: oklch(0.985 0 0);
|
||||||
--card: oklch(0.1448 0 0);
|
--card: #131313;
|
||||||
|
--card-header: #1b1b1b;
|
||||||
/* --card: oklch(0.205 0 0); ORIGINAL */
|
/* --card: oklch(0.205 0 0); ORIGINAL */
|
||||||
--card-foreground: oklch(0.985 0 0);
|
--card-foreground: oklch(0.985 0 0);
|
||||||
--popover: oklch(0.205 0 0);
|
--popover: oklch(0.205 0 0);
|
||||||
@@ -108,7 +116,7 @@ body {
|
|||||||
--muted-foreground: oklch(0.708 0 0);
|
--muted-foreground: oklch(0.708 0 0);
|
||||||
--accent: oklch(0.269 0 0);
|
--accent: oklch(0.269 0 0);
|
||||||
--accent-foreground: oklch(0.985 0 0);
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
--destructive: oklch(0.704 0.191 22.216);
|
--destructive: #ff543a;
|
||||||
--border: oklch(1 0 0 / 10%);
|
--border: oklch(1 0 0 / 10%);
|
||||||
--input: oklch(1 0 0 / 15%);
|
--input: oklch(1 0 0 / 15%);
|
||||||
--ring: oklch(0.556 0 0);
|
--ring: oklch(0.556 0 0);
|
||||||
@@ -125,6 +133,7 @@ body {
|
|||||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-border: oklch(1 0 0 / 10%);
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
--sidebar-ring: oklch(0.556 0 0);
|
--sidebar-ring: oklch(0.556 0 0);
|
||||||
|
--strong-accent: #ff543a;
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
|
|||||||
@@ -14,14 +14,20 @@ export function AppBreadcrumb() {
|
|||||||
const breadcrumbs = useBreadcrumbs();
|
const breadcrumbs = useBreadcrumbs();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Breadcrumb className={cn("mb-2", { invisible: breadcrumbs.length <= 1 })}>
|
<Breadcrumb>
|
||||||
|
<BreadcrumbLink asChild></BreadcrumbLink>
|
||||||
<BreadcrumbList>
|
<BreadcrumbList>
|
||||||
|
{breadcrumbs.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>
|
<BreadcrumbItem className={cn({ invisible: breadcrumbs.length <= 1 })}>
|
||||||
{isLast || breadcrumb.isCurrentPage ? (
|
{isLast || breadcrumb.isCurrentPage ? (
|
||||||
<BreadcrumbPage>{breadcrumb.label}</BreadcrumbPage>
|
<BreadcrumbPage>{breadcrumb.label}</BreadcrumbPage>
|
||||||
) : breadcrumb.href ? (
|
) : breadcrumb.href ? (
|
||||||
|
|||||||
@@ -6,15 +6,8 @@ import { createVolumeMutation } from "~/api-client/@tanstack/react-query.gen";
|
|||||||
import { parseError } from "~/lib/errors";
|
import { parseError } from "~/lib/errors";
|
||||||
import { CreateVolumeForm } from "./create-volume-form";
|
import { CreateVolumeForm } from "./create-volume-form";
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
import {
|
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "./ui/dialog";
|
||||||
Dialog,
|
import { ScrollArea } from "./ui/scroll-area";
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "./ui/dialog";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -40,31 +33,33 @@ export const CreateVolumeDialog = ({ open, setOpen }: Props) => {
|
|||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button className="bg-blue-900 hover:bg-blue-800">
|
<Button>
|
||||||
<Plus size={16} className="mr-2" />
|
<Plus size={16} className="mr-2" />
|
||||||
Create volume
|
Create volume
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<ScrollArea className="h-[500px] p-4">
|
||||||
<DialogTitle>Create volume</DialogTitle>
|
<DialogHeader>
|
||||||
<DialogDescription>Enter a name for the new volume</DialogDescription>
|
<DialogTitle>Create volume</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<CreateVolumeForm
|
<CreateVolumeForm
|
||||||
mode="create"
|
className="mt-4"
|
||||||
formId={formId}
|
mode="create"
|
||||||
onSubmit={(values) => {
|
formId={formId}
|
||||||
create.mutate({ body: { config: values, name: values.name } });
|
onSubmit={(values) => {
|
||||||
}}
|
create.mutate({ body: { config: values, name: values.name } });
|
||||||
/>
|
}}
|
||||||
<DialogFooter>
|
/>
|
||||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
<DialogFooter className="mt-4">
|
||||||
Cancel
|
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||||
</Button>
|
Cancel
|
||||||
<Button type="submit" form={formId} disabled={create.isPending}>
|
</Button>
|
||||||
Create
|
<Button type="submit" form={formId} disabled={create.isPending}>
|
||||||
</Button>
|
Create
|
||||||
</DialogFooter>
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</ScrollArea>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import { CheckCircle, Loader2, XCircle } from "lucide-react";
|
|||||||
import { useEffect, useState } from "react";
|
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 { 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;
|
||||||
|
|
||||||
@@ -24,6 +26,7 @@ type Props = {
|
|||||||
initialValues?: Partial<FormValues>;
|
initialValues?: Partial<FormValues>;
|
||||||
formId?: string;
|
formId?: string;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultValuesForType = {
|
const defaultValuesForType = {
|
||||||
@@ -33,9 +36,9 @@ const defaultValuesForType = {
|
|||||||
webdav: { backend: "webdav" as const, port: 80, ssl: false },
|
webdav: { backend: "webdav" as const, port: 80, ssl: false },
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, formId, loading }: 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,
|
||||||
@@ -52,22 +55,21 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
|
|||||||
form.reset({ name: watchedName, ...defaultValuesForType[watchedBackend as keyof typeof defaultValuesForType] });
|
form.reset({ name: watchedName, ...defaultValuesForType[watchedBackend as keyof typeof defaultValuesForType] });
|
||||||
}, [watchedBackend, watchedName, form.reset]);
|
}, [watchedBackend, watchedName, form.reset]);
|
||||||
|
|
||||||
const [testMessage, setTestMessage] = useState<string>("");
|
const [testMessage, setTestMessage] = useState<{ success: boolean; message: string } | null>(null);
|
||||||
|
|
||||||
const testBackendConnection = useMutation({
|
const testBackendConnection = useMutation({
|
||||||
...testConnectionMutation(),
|
...testConnectionMutation(),
|
||||||
onMutate: () => {
|
onMutate: () => {
|
||||||
setTestMessage("");
|
setTestMessage(null);
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: (error) => {
|
||||||
setTestMessage("Failed to test connection. Please try again.");
|
setTestMessage({
|
||||||
|
success: false,
|
||||||
|
message: error?.message || "Failed to test connection. Please try again.",
|
||||||
|
});
|
||||||
},
|
},
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
if (data?.success) {
|
setTestMessage(data);
|
||||||
setTestMessage(data.message);
|
|
||||||
} else {
|
|
||||||
setTestMessage(data?.message || "Connection test failed");
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -83,7 +85,7 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form id={formId} onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
<form id={formId} onSubmit={form.handleSubmit(onSubmit)} className={cn("space-y-4", className)}>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="name"
|
name="name"
|
||||||
@@ -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 />
|
||||||
@@ -423,112 +425,44 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{watchedBackend === "smb" && (
|
<div className="space-y-3">
|
||||||
<div className="space-y-3">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex items-center gap-2">
|
<Button
|
||||||
<Button
|
type="button"
|
||||||
type="button"
|
variant="outline"
|
||||||
variant="outline"
|
onClick={handleTestConnection}
|
||||||
onClick={handleTestConnection}
|
disabled={testBackendConnection.isPending}
|
||||||
disabled={testBackendConnection.isPending}
|
className="flex-1"
|
||||||
className="flex-1"
|
>
|
||||||
>
|
{testBackendConnection.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
{testBackendConnection.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
{!testBackendConnection.isPending && testMessage?.success && (
|
||||||
{testBackendConnection.isSuccess && <CheckCircle className="mr-2 h-4 w-4 text-green-500" />}
|
<CheckCircle className="mr-2 h-4 w-4 text-green-500" />
|
||||||
{testBackendConnection.isError && <XCircle className="mr-2 h-4 w-4 text-red-500" />}
|
)}
|
||||||
{testBackendConnection.isIdle && "Test Connection"}
|
{!testBackendConnection.isPending && testMessage && !testMessage.success && (
|
||||||
{testBackendConnection.isPending && "Testing..."}
|
<XCircle className="mr-2 h-4 w-4 text-red-500" />
|
||||||
{testBackendConnection.isSuccess && "Connection Successful"}
|
)}
|
||||||
{testBackendConnection.isError && "Test Failed"}
|
{testBackendConnection.isPending
|
||||||
</Button>
|
? "Testing..."
|
||||||
</div>
|
: testMessage
|
||||||
{testMessage && (
|
? testMessage.success
|
||||||
<div
|
? "Connection Successful"
|
||||||
className={`text-sm p-2 rounded-md ${
|
: "Test Failed"
|
||||||
testBackendConnection.isSuccess
|
: "Test Connection"}
|
||||||
? "bg-green-50 text-green-700 border border-green-200"
|
</Button>
|
||||||
: testBackendConnection.isError
|
|
||||||
? "bg-red-50 text-red-700 border border-red-200"
|
|
||||||
: "bg-gray-50 text-gray-700 border border-gray-200"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{testMessage}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
{testMessage && (
|
||||||
|
<div
|
||||||
{watchedBackend === "nfs" && (
|
className={cn("text-xs p-2 rounded-md text-wrap wrap-anywhere", {
|
||||||
<div className="space-y-3">
|
"bg-green-50 text-green-700 border border-green-200": testMessage.success,
|
||||||
<div className="flex items-center gap-2">
|
"bg-red-50 text-red-700 border border-red-200": !testMessage.success,
|
||||||
<Button
|
})}
|
||||||
type="button"
|
>
|
||||||
variant="outline"
|
{testMessage.message}
|
||||||
onClick={handleTestConnection}
|
|
||||||
disabled={testBackendConnection.isPending}
|
|
||||||
className="flex-1"
|
|
||||||
>
|
|
||||||
{testBackendConnection.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
||||||
{testBackendConnection.isSuccess && <CheckCircle className="mr-2 h-4 w-4 text-green-500" />}
|
|
||||||
{testBackendConnection.isError && <XCircle className="mr-2 h-4 w-4 text-red-500" />}
|
|
||||||
{testBackendConnection.isIdle && "Test Connection"}
|
|
||||||
{testBackendConnection.isPending && "Testing..."}
|
|
||||||
{testBackendConnection.isSuccess && "Connection Successful"}
|
|
||||||
{testBackendConnection.isError && "Test Failed"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
{testMessage && (
|
)}
|
||||||
<div
|
</div>
|
||||||
className={`text-sm p-2 rounded-md ${
|
|
||||||
testBackendConnection.isSuccess
|
|
||||||
? "bg-green-50 text-green-700 border border-green-200"
|
|
||||||
: testBackendConnection.isError
|
|
||||||
? "bg-red-50 text-red-700 border border-red-200"
|
|
||||||
: "bg-gray-50 text-gray-700 border border-gray-200"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{testMessage}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{watchedBackend === "webdav" && (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={handleTestConnection}
|
|
||||||
disabled={testBackendConnection.isPending}
|
|
||||||
className="flex-1"
|
|
||||||
>
|
|
||||||
{testBackendConnection.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
||||||
{testBackendConnection.isSuccess && <CheckCircle className="mr-2 h-4 w-4 text-green-500" />}
|
|
||||||
{testBackendConnection.isError && <XCircle className="mr-2 h-4 w-4 text-red-500" />}
|
|
||||||
{testBackendConnection.isIdle && "Test Connection"}
|
|
||||||
{testBackendConnection.isPending && "Testing..."}
|
|
||||||
{testBackendConnection.isSuccess && "Connection Successful"}
|
|
||||||
{testBackendConnection.isError && "Test Failed"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{testMessage && (
|
|
||||||
<div
|
|
||||||
className={`text-sm p-2 rounded-md ${
|
|
||||||
testBackendConnection.isSuccess
|
|
||||||
? "bg-green-50 text-green-700 border border-green-200"
|
|
||||||
: testBackendConnection.isError
|
|
||||||
? "bg-red-50 text-red-700 border border-red-200"
|
|
||||||
: "bg-gray-50 text-gray-700 border border-gray-200"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{testMessage}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{mode === "update" && (
|
{mode === "update" && (
|
||||||
<Button type="submit" className="w-full mt-4" loading={loading}>
|
<Button type="submit" className="w-full" loading={loading}>
|
||||||
Save Changes
|
Save Changes
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
56
apps/client/app/components/empty-state.tsx
Normal file
56
apps/client/app/components/empty-state.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { Database, HardDrive, HeartPulse, Plus } from "lucide-react";
|
||||||
|
import { CreateVolumeDialog } from "./create-volume-dialog";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
export function EmptyState() {
|
||||||
|
const [createVolumeOpen, setCreateVolumeOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-16 px-4 text-center">
|
||||||
|
<div className="relative mb-8">
|
||||||
|
<div className="absolute inset-0 animate-pulse">
|
||||||
|
<div className="w-32 h-32 rounded-full bg-primary/10 blur-2xl" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative flex items-center justify-center w-32 h-32 rounded-full bg-gradient-to-br from-primary/20 to-primary/5 border-2 border-primary/20">
|
||||||
|
<Database className="w-16 h-16 text-primary/70" strokeWidth={1.5} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-md space-y-3 mb-8">
|
||||||
|
<h3 className="text-2xl font-semibold text-foreground">No volumes yet</h3>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Get started by creating your first volume. Manage and monitor all your storage backends in one place with
|
||||||
|
advanced features like automatic mounting and health checks.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<CreateVolumeDialog open={createVolumeOpen} setOpen={setCreateVolumeOpen} />
|
||||||
|
|
||||||
|
<div className="mt-12 grid grid-cols-1 md:grid-cols-3 gap-0 max-w-3xl">
|
||||||
|
<div className="flex flex-col items-center gap-2 p-4 border bg-card-header">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center">
|
||||||
|
<Database className="w-5 h-5 text-primary" />
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
341
apps/client/app/components/file-tree.tsx
Normal file
341
apps/client/app/components/file-tree.tsx
Normal file
@@ -0,0 +1,341 @@
|
|||||||
|
/**
|
||||||
|
* 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";
|
||||||
|
|
||||||
|
const NODE_PADDING_LEFT = 12;
|
||||||
|
|
||||||
|
interface FileEntry {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
type: "file" | "directory";
|
||||||
|
size?: number;
|
||||||
|
modifiedAt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
files?: FileEntry[];
|
||||||
|
selectedFile?: string;
|
||||||
|
onFileSelect?: (filePath: string) => void;
|
||||||
|
onFolderExpand?: (folderPath: string) => void;
|
||||||
|
expandedFolders?: Set<string>;
|
||||||
|
loadingFolders?: Set<string>;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FileTree = memo((props: Props) => {
|
||||||
|
const {
|
||||||
|
files = [],
|
||||||
|
onFileSelect,
|
||||||
|
selectedFile,
|
||||||
|
onFolderExpand,
|
||||||
|
expandedFolders = new Set(),
|
||||||
|
loadingFolders = new Set(),
|
||||||
|
className,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const fileList = useMemo(() => {
|
||||||
|
return buildFileList(files);
|
||||||
|
}, [files]);
|
||||||
|
|
||||||
|
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],
|
||||||
|
);
|
||||||
|
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case "folder": {
|
||||||
|
return (
|
||||||
|
<Folder
|
||||||
|
key={fileOrFolder.id}
|
||||||
|
folder={fileOrFolder}
|
||||||
|
collapsed={collapsedFolders.has(fileOrFolder.fullPath)}
|
||||||
|
loading={loadingFolders.has(fileOrFolder.fullPath)}
|
||||||
|
onToggle={toggleCollapseState}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
interface FolderProps {
|
||||||
|
folder: FolderNode;
|
||||||
|
collapsed: boolean;
|
||||||
|
loading?: boolean;
|
||||||
|
onToggle: (fullPath: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Folder = memo(({ folder, collapsed, loading, onToggle }: FolderProps) => {
|
||||||
|
const { depth, name, fullPath } = folder;
|
||||||
|
const FolderIconComponent = collapsed ? FolderIcon : FolderOpen;
|
||||||
|
|
||||||
|
const handleClick = useCallback(() => {
|
||||||
|
onToggle(fullPath);
|
||||||
|
}, [onToggle, fullPath]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NodeButton
|
||||||
|
className={cn("group hover:bg-accent/50 text-foreground")}
|
||||||
|
depth={depth}
|
||||||
|
icon={
|
||||||
|
loading ? (
|
||||||
|
<Loader2 className="w-4 h-4 shrink-0 animate-spin" />
|
||||||
|
) : collapsed ? (
|
||||||
|
<ChevronRight className="w-4 h-4 shrink-0" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="w-4 h-4 shrink-0" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onClick={handleClick}
|
||||||
|
>
|
||||||
|
<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;
|
||||||
|
}
|
||||||
|
|
||||||
|
const File = memo(({ file, onFileSelect, selected }: FileProps) => {
|
||||||
|
const { depth, name, fullPath } = file;
|
||||||
|
|
||||||
|
const handleClick = useCallback(() => {
|
||||||
|
onFileSelect(fullPath);
|
||||||
|
}, [onFileSelect, fullPath]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NodeButton
|
||||||
|
className={cn("group", {
|
||||||
|
"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}
|
||||||
|
>
|
||||||
|
<span className="truncate">{name}</span>
|
||||||
|
</NodeButton>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
interface ButtonProps {
|
||||||
|
depth: number;
|
||||||
|
icon: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NodeButton = memo(({ depth, icon, onClick, 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}
|
||||||
|
>
|
||||||
|
{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[]): Node[] {
|
||||||
|
const fileMap = new Map<string, Node>();
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
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" });
|
||||||
|
}
|
||||||
25
apps/client/app/components/grid-background.tsx
Normal file
25
apps/client/app/components/grid-background.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
|
interface GridBackgroundProps {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
containerClassName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GridBackground({ children, className, containerClassName }: GridBackgroundProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"relative min-h-dvh w-full overflow-x-hidden",
|
||||||
|
"[background-size:20px_20px] sm:[background-size:40px_40px]",
|
||||||
|
"[background-image:linear-gradient(to_right,#e4e4e7_1px,transparent_1px),linear-gradient(to_bottom,#e4e4e7_1px,transparent_1px)]",
|
||||||
|
"dark:[background-image:linear-gradient(to_right,#262626_1px,transparent_1px),linear-gradient(to_bottom,#262626_1px,transparent_1px)]",
|
||||||
|
containerClassName,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-white [mask-image:radial-gradient(ellipse_at_center,transparent_20%,black)] dark:bg-card" />
|
||||||
|
<div className={cn("relative h-screen", className)}>{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,22 +1,69 @@
|
|||||||
import { Outlet } from "react-router";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { cn } from "~/lib/utils";
|
import { LifeBuoy } from "lucide-react";
|
||||||
|
import { Outlet, useNavigate } from "react-router";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { logoutMutation } from "~/api-client/@tanstack/react-query.gen";
|
||||||
|
import { appContext } from "~/context";
|
||||||
|
import { authMiddleware } from "~/middleware/auth";
|
||||||
|
import type { Route } from "./+types/layout";
|
||||||
import { AppBreadcrumb } from "./app-breadcrumb";
|
import { AppBreadcrumb } from "./app-breadcrumb";
|
||||||
|
import { GridBackground } from "./grid-background";
|
||||||
|
import { Button } from "./ui/button";
|
||||||
|
|
||||||
|
export const clientMiddleware = [authMiddleware];
|
||||||
|
|
||||||
|
export async function clientLoader({ context }: Route.LoaderArgs) {
|
||||||
|
const ctx = context.get(appContext);
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Layout({ loaderData }: Route.ComponentProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const logout = useMutation({
|
||||||
|
...logoutMutation(),
|
||||||
|
onSuccess: async () => {
|
||||||
|
navigate("/login", { replace: true });
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error(error);
|
||||||
|
toast.error("Logout failed");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export default function Layout() {
|
|
||||||
return (
|
return (
|
||||||
<div
|
<GridBackground>
|
||||||
className={cn(
|
<header className="bg-card-header border-b border-border/50">
|
||||||
"relative min-h-dvh w-full",
|
<div className="flex items-center justify-between py-3 sm:py-4 px-2 sm:px-4 container mx-auto">
|
||||||
"[background-size:40px_40px]",
|
<AppBreadcrumb />
|
||||||
"[background-image:linear-gradient(to_right,#e4e4e7_1px,transparent_1px),linear-gradient(to_bottom,#e4e4e7_1px,transparent_1px)]",
|
{loaderData.user && (
|
||||||
"dark:[background-image:linear-gradient(to_right,#262626_1px,transparent_1px),linear-gradient(to_bottom,#262626_1px,transparent_1px)]",
|
<div className="flex items-center gap-4">
|
||||||
)}
|
<span className="text-sm text-muted-foreground">
|
||||||
>
|
Welcome, <span className="text-strong-accent">{loaderData.user?.username}</span>
|
||||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-white [mask-image:radial-gradient(ellipse_at_center,transparent_20%,black)] dark:bg-black"></div>
|
</span>
|
||||||
<main className="relative flex flex-col pt-8 p-4 container mx-auto">
|
<Button variant="outline" size="sm" onClick={() => logout.mutate({})} loading={logout.isPending}>
|
||||||
<AppBreadcrumb />
|
Logout
|
||||||
|
</Button>
|
||||||
|
<Button variant="default" size="sm" className="relative overflow-hidden">
|
||||||
|
<a
|
||||||
|
href="https://github.com/nicotsx/ironmount/issues/new"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<LifeBuoy />
|
||||||
|
<span>Report an issue</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main className="flex flex-col pt-4 sm:pt-8 px-2 sm:px-4 pb-4 container mx-auto">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</GridBackground>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,23 +6,23 @@ import type * as React from "react";
|
|||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
"inline-flex cursor-pointer uppercase border items-center justify-center dark:border-white dark:bg-secondary dark:text-white gap-2 whitespace-nowrap text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
"inline-flex cursor-pointer uppercase rounded-sm items-center justify-center gap-2 whitespace-nowrap text-xs font-semibold tracking-wide transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-ring border-0",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
default: "bg-transparent text-white hover:bg-[#3A3A3A]/80 border dark:text-white dark:hover:bg-[#3A3A3A]/80",
|
||||||
|
primary: "bg-strong-accent text-white hover:bg-strong-accent/90 focus-visible:ring-strong-accent/50",
|
||||||
destructive:
|
destructive:
|
||||||
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
"border border-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/50 text-destructive hover:text-white",
|
||||||
outline:
|
outline: "border border-border bg-background hover:bg-accent hover:text-accent-foreground",
|
||||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
secondary: "bg-transparent text-white hover:bg-[#3A3A3A]/80 border dark:text-white dark:hover:bg-[#3A3A3A]/80",
|
||||||
secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||||
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
|
||||||
link: "text-primary underline-offset-4 hover:underline",
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
default: "h-9 px-5 py-2 has-[>svg]:px-4",
|
||||||
sm: "rounded-xs h-8 gap-1.5 px-3 has-[>svg]:px-2.5",
|
sm: "h-8 px-3 py-1.5 has-[>svg]:px-2.5",
|
||||||
lg: "h-10 px-6 has-[>svg]:px-4",
|
lg: "h-10 px-6 py-2.5 has-[>svg]:px-5",
|
||||||
icon: "size-9",
|
icon: "size-9",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,17 +1,26 @@
|
|||||||
import * as React from "react";
|
import type * as React from "react";
|
||||||
|
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
function Card({ className, children, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="card"
|
data-slot="card"
|
||||||
className={cn(
|
className={cn("bg-card text-card-foreground relative flex flex-col gap-6 border-2 py-6 shadow-sm", className)}
|
||||||
"bg-card text-card-foreground flex flex-col gap-6 border-2 py-6 shadow-sm",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
>
|
||||||
|
<span aria-hidden="true" className="pointer-events-none absolute inset-0 z-10 select-none">
|
||||||
|
<span className="absolute left-[-2px] top-[-2px] h-0.5 w-4 bg-white/80" />
|
||||||
|
<span className="absolute left-[-2px] top-[-2px] h-4 w-0.5 bg-white/80" />
|
||||||
|
<span className="absolute right-[-2px] top-[-2px] h-0.5 w-4 bg-white/80" />
|
||||||
|
<span className="absolute right-[-2px] top-[-2px] h-4 w-0.5 bg-white/80" />
|
||||||
|
<span className="absolute left-[-2px] bottom-[-2px] h-0.5 w-4 bg-white/80" />
|
||||||
|
<span className="absolute left-[-2px] bottom-[-2px] h-4 w-0.5 bg-white/80" />
|
||||||
|
<span className="absolute right-[-2px] bottom-[-2px] h-0.5 w-4 bg-white/80" />
|
||||||
|
<span className="absolute right-[-2px] bottom-[-2px] h-4 w-0.5 bg-white/80" />
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,64 +38,31 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return <div data-slot="card-title" className={cn("leading-none font-semibold", className)} {...props} />;
|
||||||
<div
|
|
||||||
data-slot="card-title"
|
|
||||||
className={cn("leading-none font-semibold", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return <div data-slot="card-description" className={cn("text-muted-foreground text-sm", className)} {...props} />;
|
||||||
<div
|
|
||||||
data-slot="card-description"
|
|
||||||
className={cn("text-muted-foreground text-sm", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="card-action"
|
data-slot="card-action"
|
||||||
className={cn(
|
className={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
|
||||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return <div data-slot="card-content" className={cn("px-6", className)} {...props} />;
|
||||||
<div
|
|
||||||
data-slot="card-content"
|
|
||||||
className={cn("px-6", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div data-slot="card-footer" className={cn("flex items-center px-6 [.border-t]:pt-6", className)} {...props} />
|
||||||
data-slot="card-footer"
|
|
||||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent };
|
||||||
Card,
|
|
||||||
CardHeader,
|
|
||||||
CardFooter,
|
|
||||||
CardTitle,
|
|
||||||
CardAction,
|
|
||||||
CardDescription,
|
|
||||||
CardContent,
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
import React, { useEffect } from "react";
|
import type React from "react";
|
||||||
import Prism from "prismjs";
|
|
||||||
import "prismjs/themes/prism-twilight.css";
|
|
||||||
import "prismjs/components/prism-yaml";
|
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { copyToClipboard } from "~/utils/clipboard";
|
import { copyToClipboard } from "~/utils/clipboard";
|
||||||
|
|
||||||
@@ -11,35 +8,31 @@ interface CodeBlockProps {
|
|||||||
filename?: string;
|
filename?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CodeBlock: React.FC<CodeBlockProps> = ({ code, language = "jsx", filename }) => {
|
export const CodeBlock: React.FC<CodeBlockProps> = ({ code, filename }) => {
|
||||||
useEffect(() => {
|
|
||||||
Prism.highlightAll();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleCopy = async () => {
|
const handleCopy = async () => {
|
||||||
await copyToClipboard(code);
|
await copyToClipboard(code);
|
||||||
toast.success("Code copied to clipboard");
|
toast.success("Code copied to clipboard");
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-hidden rounded-sm bg-slate-900 ring-1 ring-white/10">
|
<div className="overflow-hidden rounded-sm bg-card-header ring-1 ring-white/10">
|
||||||
<div className="flex items-center justify-between border-b border-white/10 px-4 py-2 text-xs text-slate-400">
|
<div className="flex items-center justify-between border-b border-white/10 px-4 py-2 text-xs">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="h-2.5 w-2.5 rounded-full bg-rose-500" />
|
<span className="h-2.5 w-2.5 rounded-full bg-rose-500" />
|
||||||
<span className="h-2.5 w-2.5 rounded-full bg-amber-500" />
|
<span className="h-2.5 w-2.5 rounded-full bg-amber-500" />
|
||||||
<span className="h-2.5 w-2.5 rounded-full bg-emerald-500" />
|
<span className="h-2.5 w-2.5 rounded-full bg-emerald-500" />
|
||||||
{filename && <span className="ml-3 font-medium text-slate-300">{filename}</span>}
|
{filename && <span className="ml-3 font-medium">{filename}</span>}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleCopy()}
|
onClick={() => handleCopy()}
|
||||||
className="cursor-pointer rounded-md bg-white/5 px-2 py-1 text-[11px] font-medium text-slate-300 ring-1 ring-inset ring-white/10 transition hover:bg-white/10 active:translate-y-px"
|
className="cursor-pointer rounded-md bg-white/5 px-2 py-1 text-[11px] font-medium ring-1 ring-inset ring-white/10 transition hover:bg-white/10 active:translate-y-px"
|
||||||
>
|
>
|
||||||
Copy
|
Copy
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<pre className="overflow-x-auto leading-6 text-xs m-0" style={{ marginTop: 0, marginBottom: 0 }}>
|
<pre className="text-xs m-0 px-4 py-2 bg-card-header">
|
||||||
<code className={`language-${language}`}>{code}</code>
|
<code className="text-white/80">{code}</code>
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import * as React from "react";
|
import type * as React from "react";
|
||||||
|
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
@@ -8,7 +8,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
|||||||
type={type}
|
type={type}
|
||||||
data-slot="input"
|
data-slot="input"
|
||||||
className={cn(
|
className={cn(
|
||||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 border bg-transparent px-3 py-1 text-sm shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
className,
|
className,
|
||||||
|
|||||||
56
apps/client/app/components/ui/scroll-area.tsx
Normal file
56
apps/client/app/components/ui/scroll-area.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
||||||
|
|
||||||
|
import { cn } from "~/lib/utils"
|
||||||
|
|
||||||
|
function ScrollArea({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<ScrollAreaPrimitive.Root
|
||||||
|
data-slot="scroll-area"
|
||||||
|
className={cn("relative", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.Viewport
|
||||||
|
data-slot="scroll-area-viewport"
|
||||||
|
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ScrollAreaPrimitive.Viewport>
|
||||||
|
<ScrollBar />
|
||||||
|
<ScrollAreaPrimitive.Corner />
|
||||||
|
</ScrollAreaPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScrollBar({
|
||||||
|
className,
|
||||||
|
orientation = "vertical",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||||
|
return (
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||||
|
data-slot="scroll-area-scrollbar"
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"flex touch-none p-px transition-colors select-none",
|
||||||
|
orientation === "vertical" &&
|
||||||
|
"h-full w-2.5 border-l border-l-transparent",
|
||||||
|
orientation === "horizontal" &&
|
||||||
|
"h-2.5 flex-col border-t border-t-transparent",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||||
|
data-slot="scroll-area-thumb"
|
||||||
|
className="bg-border relative flex-1 rounded-full"
|
||||||
|
/>
|
||||||
|
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ScrollArea, ScrollBar }
|
||||||
@@ -1,64 +1,58 @@
|
|||||||
import * as React from "react"
|
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
import type * as React from "react";
|
||||||
|
|
||||||
import { cn } from "~/lib/utils"
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
function Tabs({
|
function Tabs({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||||
className,
|
return <TabsPrimitive.Root data-slot="tabs" className={cn("flex flex-col gap-2", className)} {...props} />;
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
|
||||||
return (
|
|
||||||
<TabsPrimitive.Root
|
|
||||||
data-slot="tabs"
|
|
||||||
className={cn("flex flex-col gap-2", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function TabsList({
|
function TabsList({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.List>) {
|
||||||
className,
|
return (
|
||||||
...props
|
<TabsPrimitive.List
|
||||||
}: React.ComponentProps<typeof TabsPrimitive.List>) {
|
data-slot="tabs-list"
|
||||||
return (
|
className={cn("inline-flex h-7 items-center gap-4 text-xs text-muted-foreground", className)}
|
||||||
<TabsPrimitive.List
|
{...props}
|
||||||
data-slot="tabs-list"
|
/>
|
||||||
className={cn(
|
);
|
||||||
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function TabsTrigger({
|
function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||||
className,
|
return (
|
||||||
...props
|
<TabsPrimitive.Trigger
|
||||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
data-slot="tabs-trigger"
|
||||||
return (
|
className={cn(
|
||||||
<TabsPrimitive.Trigger
|
"cursor-pointer group relative inline-flex h-7 items-center whitespace-nowrap text-xs font-medium transition-colors",
|
||||||
data-slot="tabs-trigger"
|
"text-muted-foreground data-[state=active]:text-foreground disabled:pointer-events-none disabled:opacity-50",
|
||||||
className={cn(
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive/40 focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
||||||
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
|
||||||
className
|
// Padding: 20px horizontal (8px for bracket tick + 12px gap to text)
|
||||||
)}
|
"px-5",
|
||||||
{...props}
|
// Transparent orange background for active state
|
||||||
/>
|
"data-[state=active]:bg-[#FF453A]/10",
|
||||||
)
|
// Left bracket - vertical line
|
||||||
|
"before:absolute before:left-0 before:top-0 before:h-7 before:w-0.5 before:bg-[#5D6570] before:transition-colors data-[state=active]:before:bg-[#FF453A]",
|
||||||
|
// Left bracket - top tick
|
||||||
|
"after:absolute after:left-0 after:top-[-1px] after:w-2 after:h-0.5 after:bg-[#5D6570] after:transition-colors data-[state=active]:after:bg-[#FF453A]",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="relative z-10">{props.children}</span>
|
||||||
|
{/* Left bracket - bottom tick */}
|
||||||
|
<span className="absolute left-0 bottom-[-1px] h-0.5 w-2 bg-[#5D6570] transition-colors group-data-[state=active]:bg-[#FF453A]" />
|
||||||
|
{/* Right bracket - top tick */}
|
||||||
|
<span className="absolute right-0 top-[-1px] h-0.5 w-2 bg-[#5D6570] transition-colors group-data-[state=active]:bg-[#FF453A]" />
|
||||||
|
{/* Right bracket - vertical line */}
|
||||||
|
<span className="absolute right-0 top-0 h-7 w-0.5 bg-[#5D6570] transition-colors group-data-[state=active]:bg-[#FF453A]" />
|
||||||
|
{/* Right bracket - bottom tick */}
|
||||||
|
<span className="absolute right-0 bottom-[-1px] h-0.5 w-2 bg-[#5D6570] transition-colors group-data-[state=active]:bg-[#FF453A]" />
|
||||||
|
</TabsPrimitive.Trigger>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TabsContent({
|
function TabsContent({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||||
className,
|
return <TabsPrimitive.Content data-slot="tabs-content" className={cn("flex-1 outline-none", className)} {...props} />;
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
|
||||||
return (
|
|
||||||
<TabsPrimitive.Content
|
|
||||||
data-slot="tabs-content"
|
|
||||||
className={cn("flex-1 outline-none", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||||
|
|||||||
@@ -42,10 +42,10 @@ const getIconAndColor = (backend: BackendType) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const VolumeIcon = ({ backend, size = 10 }: VolumeIconProps) => {
|
export const VolumeIcon = ({ backend, size = 10 }: VolumeIconProps) => {
|
||||||
const { icon: Icon, color, label } = getIconAndColor(backend);
|
const { icon: Icon, label } = getIconAndColor(backend);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className={`flex items-center gap-2 ${color} rounded-md px-2 py-1`}>
|
<span className={`flex items-center gap-2 rounded-md px-2 py-1`}>
|
||||||
<Icon size={size} />
|
<Icon size={size} />
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
12
apps/client/app/context.ts
Normal file
12
apps/client/app/context.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { createContext } from "react-router";
|
||||||
|
import type { User } from "./lib/types";
|
||||||
|
|
||||||
|
type AppContext = {
|
||||||
|
user: User | null;
|
||||||
|
hasUsers: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const appContext = createContext<AppContext>({
|
||||||
|
user: null,
|
||||||
|
hasUsers: false,
|
||||||
|
});
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import type { GetVolumeResponse } from "~/api-client";
|
import type { GetMeResponse, GetVolumeResponse } 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"];
|
||||||
|
|||||||
18
apps/client/app/middleware/auth.ts
Normal file
18
apps/client/app/middleware/auth.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { redirect, type MiddlewareFunction } from "react-router";
|
||||||
|
import { getMe, getStatus } from "~/api-client";
|
||||||
|
import { appContext } from "~/context";
|
||||||
|
|
||||||
|
export const authMiddleware: MiddlewareFunction = async ({ context }) => {
|
||||||
|
const session = await getMe();
|
||||||
|
|
||||||
|
if (!session.data?.user.id) {
|
||||||
|
const status = await getStatus();
|
||||||
|
if (!status.data?.hasUsers) {
|
||||||
|
throw redirect("/onboarding");
|
||||||
|
}
|
||||||
|
|
||||||
|
throw redirect("/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
context.set(appContext, { user: session.data.user, hasUsers: true });
|
||||||
|
};
|
||||||
@@ -18,7 +18,7 @@ export function StorageChart({ statfs }: Props) {
|
|||||||
{
|
{
|
||||||
name: "Used",
|
name: "Used",
|
||||||
value: statfs.used,
|
value: statfs.used,
|
||||||
fill: "#2B7EFF",
|
fill: "#ff543a",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Free",
|
name: "Free",
|
||||||
@@ -63,7 +63,7 @@ export function StorageChart({ statfs }: Props) {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex-1 pb-0">
|
<CardContent className="flex-1 pb-0">
|
||||||
<div className="">
|
<div>
|
||||||
<ChartContainer config={chartConfig} className="mx-auto aspect-square max-h-[250px]">
|
<ChartContainer config={chartConfig} className="mx-auto aspect-square max-h-[250px]">
|
||||||
<PieChart>
|
<PieChart>
|
||||||
<ChartTooltip
|
<ChartTooltip
|
||||||
@@ -105,9 +105,9 @@ export function StorageChart({ statfs }: Props) {
|
|||||||
<ByteSize bytes={statfs.total} className="font-mono text-sm" />
|
<ByteSize bytes={statfs.total} className="font-mono text-sm" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between p-3 rounded-lg bg-blue-500/10">
|
<div className="flex items-center justify-between p-3 rounded-lg bg-strong-accent/10">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="h-4 w-4 rounded-full bg-blue-500" />
|
<div className="h-4 w-4 rounded-full bg-strong-accent" />
|
||||||
<span className="font-medium">Used Space</span>
|
<span className="font-medium">Used Space</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
|
|||||||
146
apps/client/app/modules/details/tabs/files.tsx
Normal file
146
apps/client/app/modules/details/tabs/files.tsx
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { FolderOpen } from "lucide-react";
|
||||||
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
import { listFilesOptions } from "~/api-client/@tanstack/react-query.gen";
|
||||||
|
import { listFiles } from "~/api-client/sdk.gen";
|
||||||
|
import { FileTree } from "~/components/file-tree";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
|
import { parseError } from "~/lib/errors";
|
||||||
|
import type { Volume } from "~/lib/types";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
volume: Volume;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface FileEntry {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
type: "file" | "directory";
|
||||||
|
size?: number;
|
||||||
|
modifiedAt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FilesTabContent = ({ volume }: Props) => {
|
||||||
|
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
|
||||||
|
const [fetchedFolders, setFetchedFolders] = useState<Set<string>>(new Set(["/"]));
|
||||||
|
const [loadingFolders, setLoadingFolders] = useState<Set<string>>(new Set());
|
||||||
|
const [allFiles, setAllFiles] = useState<Map<string, FileEntry>>(new Map());
|
||||||
|
|
||||||
|
// Fetch root level files
|
||||||
|
const { data, isLoading, error } = useQuery({
|
||||||
|
...listFilesOptions({ path: { name: volume.name } }),
|
||||||
|
enabled: volume.status === "mounted",
|
||||||
|
refetchInterval: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
useMemo(() => {
|
||||||
|
if (data?.files) {
|
||||||
|
setAllFiles((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
for (const file of data.files) {
|
||||||
|
next.set(file.path, file);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const handleFolderExpand = useCallback(
|
||||||
|
async (folderPath: string) => {
|
||||||
|
setExpandedFolders((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.add(folderPath);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!fetchedFolders.has(folderPath)) {
|
||||||
|
setLoadingFolders((prev) => new Set(prev).add(folderPath));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await listFiles({
|
||||||
|
path: { name: volume.name },
|
||||||
|
query: { path: folderPath },
|
||||||
|
throwOnError: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.data?.files) {
|
||||||
|
setAllFiles((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
for (const file of result.data.files) {
|
||||||
|
next.set(file.path, file);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setFetchedFolders((prev) => new Set(prev).add(folderPath));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch folder contents:", error);
|
||||||
|
} finally {
|
||||||
|
setLoadingFolders((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(folderPath);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[volume.name, fetchedFolders],
|
||||||
|
);
|
||||||
|
|
||||||
|
const fileArray = useMemo(() => Array.from(allFiles.values()), [allFiles]);
|
||||||
|
|
||||||
|
if (volume.status !== "mounted") {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-col items-center justify-center text-center py-12">
|
||||||
|
<FolderOpen className="mb-4 h-12 w-12 text-muted-foreground" />
|
||||||
|
<p className="text-muted-foreground">Volume must be mounted to browse files.</p>
|
||||||
|
<p className="text-sm text-muted-foreground mt-2">Mount the volume to explore its contents.</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="h-[600px] flex flex-col">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>File Explorer</CardTitle>
|
||||||
|
<CardDescription>Browse the files and folders in this volume.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex-1 overflow-hidden flex flex-col">
|
||||||
|
{isLoading && (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<p className="text-muted-foreground">Loading files...</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<p className="text-destructive">Failed to load files: {error.message}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isLoading && !error && (
|
||||||
|
<div className="overflow-auto flex-1 border rounded-md bg-card">
|
||||||
|
{fileArray.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full text-center p-8">
|
||||||
|
<FolderOpen className="mb-4 h-12 w-12 text-muted-foreground" />
|
||||||
|
<p className="text-muted-foreground">This volume is empty.</p>
|
||||||
|
<p className="text-sm text-muted-foreground mt-2">
|
||||||
|
Files and folders will appear here once you add them.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<FileTree
|
||||||
|
files={fileArray}
|
||||||
|
onFolderExpand={handleFolderExpand}
|
||||||
|
expandedFolders={expandedFolders}
|
||||||
|
loadingFolders={loadingFolders}
|
||||||
|
className="p-2"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -40,7 +40,7 @@ export function Layout({ children }: { children: React.ReactNode }) {
|
|||||||
<html lang="en" style={{ colorScheme: "dark" }} className="dark">
|
<html lang="en" style={{ colorScheme: "dark" }} className="dark">
|
||||||
<head>
|
<head>
|
||||||
<meta charSet="utf-8" />
|
<meta charSet="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
|
||||||
<Meta />
|
<Meta />
|
||||||
<Links />
|
<Links />
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { index, layout, type RouteConfig, route } from "@react-router/dev/routes";
|
import { index, layout, type RouteConfig, route } from "@react-router/dev/routes";
|
||||||
|
|
||||||
export default [
|
export default [
|
||||||
|
route("onboarding", "./routes/onboarding.tsx"),
|
||||||
|
route("login", "./routes/login.tsx"),
|
||||||
layout("./components/layout.tsx", [index("./routes/home.tsx"), route("volumes/:name", "./routes/details.tsx")]),
|
layout("./components/layout.tsx", [index("./routes/home.tsx"), route("volumes/:name", "./routes/details.tsx")]),
|
||||||
] satisfies RouteConfig;
|
] satisfies RouteConfig;
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import { useNavigate, useParams } from "react-router";
|
import { useNavigate, useParams } from "react-router";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { getVolume } from "~/api-client";
|
|
||||||
import {
|
import {
|
||||||
deleteVolumeMutation,
|
deleteVolumeMutation,
|
||||||
getVolumeOptions,
|
getVolumeOptions,
|
||||||
@@ -16,7 +15,9 @@ import { parseError } from "~/lib/errors";
|
|||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
import { VolumeBackupsTabContent } from "~/modules/details/tabs/backups";
|
import { VolumeBackupsTabContent } from "~/modules/details/tabs/backups";
|
||||||
import { DockerTabContent } from "~/modules/details/tabs/docker";
|
import { DockerTabContent } from "~/modules/details/tabs/docker";
|
||||||
|
import { FilesTabContent } from "~/modules/details/tabs/files";
|
||||||
import { VolumeInfoTabContent } from "~/modules/details/tabs/info";
|
import { VolumeInfoTabContent } from "~/modules/details/tabs/info";
|
||||||
|
import { getVolume } from "../api-client";
|
||||||
import type { Route } from "./+types/details";
|
import type { Route } from "./+types/details";
|
||||||
|
|
||||||
export function meta({ params }: Route.MetaArgs) {
|
export function meta({ params }: Route.MetaArgs) {
|
||||||
@@ -111,7 +112,6 @@ export default function DetailsPage({ loaderData }: Route.ComponentProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
|
||||||
onClick={() => mountVol.mutate({ path: { name } })}
|
onClick={() => mountVol.mutate({ path: { name } })}
|
||||||
loading={mountVol.isPending}
|
loading={mountVol.isPending}
|
||||||
className={cn({ hidden: volume.status === "mounted" })}
|
className={cn({ hidden: volume.status === "mounted" })}
|
||||||
@@ -131,15 +131,19 @@ export default function DetailsPage({ loaderData }: Route.ComponentProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Tabs defaultValue="info" className="mt-0">
|
<Tabs defaultValue="info" className="mt-4">
|
||||||
<TabsList>
|
<TabsList className="mb-2">
|
||||||
<TabsTrigger value="info">Configuration</TabsTrigger>
|
<TabsTrigger value="info">Configuration</TabsTrigger>
|
||||||
|
<TabsTrigger value="files">Files</TabsTrigger>
|
||||||
<TabsTrigger value="docker">Docker</TabsTrigger>
|
<TabsTrigger value="docker">Docker</TabsTrigger>
|
||||||
<TabsTrigger value="backups">Backups</TabsTrigger>
|
<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>
|
||||||
|
|||||||
@@ -5,11 +5,13 @@ import { useNavigate } from "react-router";
|
|||||||
import { listVolumes } from "~/api-client";
|
import { listVolumes } from "~/api-client";
|
||||||
import { listVolumesOptions } from "~/api-client/@tanstack/react-query.gen";
|
import { listVolumesOptions } from "~/api-client/@tanstack/react-query.gen";
|
||||||
import { CreateVolumeDialog } from "~/components/create-volume-dialog";
|
import { CreateVolumeDialog } from "~/components/create-volume-dialog";
|
||||||
|
import { EmptyState } from "~/components/empty-state";
|
||||||
import { StatusDot } from "~/components/status-dot";
|
import { StatusDot } from "~/components/status-dot";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Card } from "~/components/ui/card";
|
||||||
import { Input } from "~/components/ui/input";
|
import { Input } from "~/components/ui/input";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select";
|
||||||
import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "~/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/components/ui/table";
|
||||||
import { VolumeIcon } from "~/components/volume-icon";
|
import { VolumeIcon } from "~/components/volume-icon";
|
||||||
import type { Route } from "./+types/home";
|
import type { Route } from "./+types/home";
|
||||||
|
|
||||||
@@ -58,22 +60,29 @@ export default function Home({ loaderData }: Route.ComponentProps) {
|
|||||||
return matchesSearch && matchesStatus && matchesBackend;
|
return matchesSearch && matchesStatus && matchesBackend;
|
||||||
}) || [];
|
}) || [];
|
||||||
|
|
||||||
|
const hasNoVolumes = data?.volumes.length === 0;
|
||||||
|
const hasNoFilteredVolumes = filteredVolumes.length === 0 && !hasNoVolumes;
|
||||||
|
|
||||||
|
if (hasNoVolumes) {
|
||||||
|
return (
|
||||||
|
<Card className="p-0 gap-0">
|
||||||
|
<EmptyState />
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Card className="p-0 gap-0">
|
||||||
<h1 className="text-3xl font-bold mb-0 uppercase">Ironmount</h1>
|
<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">
|
||||||
<h2 className="text-sm font-semibold mb-2 text-muted-foreground">
|
<span className="flex flex-col sm:flex-row items-stretch md:items-center gap-0 flex-wrap ">
|
||||||
Create, manage, monitor, and automate your volumes with ease.
|
|
||||||
</h2>
|
|
||||||
<div className="flex items-center gap-2 mt-4 justify-between">
|
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
<Input
|
<Input
|
||||||
className="w-[180px]"
|
className="w-full lg:w-[180px] min-w-[180px] mr-[-1px] mt-[-1px]"
|
||||||
placeholder="Search volumes…"
|
placeholder="Search volumes…"
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||||
<SelectTrigger className="w-[180px]">
|
<SelectTrigger className="w-full lg:w-[180px] min-w-[180px] mr-[-1px] mt-[-1px]">
|
||||||
<SelectValue placeholder="All status" />
|
<SelectValue placeholder="All status" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -83,7 +92,7 @@ export default function Home({ loaderData }: Route.ComponentProps) {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Select value={backendFilter} onValueChange={setBackendFilter}>
|
<Select value={backendFilter} onValueChange={setBackendFilter}>
|
||||||
<SelectTrigger className="w-[180px]">
|
<SelectTrigger className="w-full lg:w-[180px] min-w-[180px] mt-[-1px]">
|
||||||
<SelectValue placeholder="All backends" />
|
<SelectValue placeholder="All backends" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -93,7 +102,7 @@ export default function Home({ loaderData }: Route.ComponentProps) {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
{(searchQuery || statusFilter || backendFilter) && (
|
{(searchQuery || statusFilter || backendFilter) && (
|
||||||
<Button variant="outline" size="sm" onClick={clearFilters}>
|
<Button onClick={clearFilters} className="w-full lg:w-auto mt-2 lg:mt-0 lg:ml-2">
|
||||||
<RotateCcw className="h-4 w-4 mr-2" />
|
<RotateCcw className="h-4 w-4 mr-2" />
|
||||||
Clear filters
|
Clear filters
|
||||||
</Button>
|
</Button>
|
||||||
@@ -101,42 +110,67 @@ export default function Home({ loaderData }: Route.ComponentProps) {
|
|||||||
</span>
|
</span>
|
||||||
<CreateVolumeDialog open={createVolumeOpen} setOpen={setCreateVolumeOpen} />
|
<CreateVolumeDialog open={createVolumeOpen} setOpen={setCreateVolumeOpen} />
|
||||||
</div>
|
</div>
|
||||||
<Table className="mt-4 border bg-white dark:bg-secondary">
|
<div className="overflow-x-auto">
|
||||||
<TableCaption>A list of your managed volumes.</TableCaption>
|
<Table className="border-t">
|
||||||
<TableHeader>
|
<TableHeader className="bg-card-header">
|
||||||
<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">Mountpoint</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>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{filteredVolumes.map((volume) => (
|
|
||||||
<TableRow
|
|
||||||
key={volume.name}
|
|
||||||
className="hover:bg-accent/50 hover:cursor-pointer"
|
|
||||||
onClick={() => navigate(`/volumes/${volume.name}`)}
|
|
||||||
>
|
|
||||||
<TableCell className="font-medium">{volume.name}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<VolumeIcon backend={volume.type} />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
<span className="text-muted-foreground text-xs truncate bg-primary/10 rounded-md px-2 py-1">
|
|
||||||
{volume.path}
|
|
||||||
</span>
|
|
||||||
<Copy size={10} />
|
|
||||||
</span>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-center">
|
|
||||||
<StatusDot status={volume.status} />
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
</TableHeader>
|
||||||
</TableBody>
|
<TableBody>
|
||||||
</Table>
|
{hasNoFilteredVolumes ? (
|
||||||
</>
|
<TableRow>
|
||||||
|
<TableCell colSpan={4} className="text-center py-12">
|
||||||
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
<p className="text-muted-foreground">No volumes match your filters.</p>
|
||||||
|
<Button onClick={clearFilters} variant="outline" size="sm">
|
||||||
|
<RotateCcw className="h-4 w-4 mr-2" />
|
||||||
|
Clear filters
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
filteredVolumes.map((volume) => (
|
||||||
|
<TableRow
|
||||||
|
key={volume.name}
|
||||||
|
className="hover:bg-accent/50 hover:cursor-pointer"
|
||||||
|
onClick={() => navigate(`/volumes/${volume.name}`)}
|
||||||
|
>
|
||||||
|
<TableCell className="font-medium text-strong-accent">{volume.name}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<VolumeIcon backend={volume.type} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="hidden sm:table-cell">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span className="text-muted-foreground text-xs truncate bg-primary/10 rounded-md px-2 py-1">
|
||||||
|
{volume.path}
|
||||||
|
</span>
|
||||||
|
<Copy size={10} />
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<StatusDot status={volume.status} />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
<div className="px-4 py-2 text-sm text-muted-foreground bg-card-header flex justify-end border-t">
|
||||||
|
{hasNoFilteredVolumes ? (
|
||||||
|
"No volumes match filters."
|
||||||
|
) : (
|
||||||
|
<span>
|
||||||
|
<span className="text-strong-accent">{filteredVolumes.length}</span> volume
|
||||||
|
{filteredVolumes.length > 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
103
apps/client/app/routes/login.tsx
Normal file
103
apps/client/app/routes/login.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
133
apps/client/app/routes/onboarding.tsx
Normal file
133
apps/client/app/routes/onboarding.tsx
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
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) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (obj && typeof obj === "object") {
|
||||||
|
return Object.entries(obj).reduce((acc, [key, value]) => {
|
||||||
|
const cleaned = deepClean(value);
|
||||||
|
if (cleaned !== undefined) acc[key as keyof T] = cleaned;
|
||||||
|
return acc;
|
||||||
|
}, {} as T);
|
||||||
|
}
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
@@ -9,49 +9,50 @@
|
|||||||
"tsc": "react-router typegen && tsc"
|
"tsc": "react-router typegen && tsc"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^5.2.1",
|
"@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-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-select": "^2.2.5",
|
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||||
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@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",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@react-router/node": "^7.7.1",
|
"@react-router/node": "^7.9.3",
|
||||||
"@react-router/serve": "^7.7.1",
|
"@react-router/serve": "^7.9.3",
|
||||||
"@tanstack/react-query": "^5.84.2",
|
"@tanstack/react-query": "^5.90.2",
|
||||||
"@tanstack/react-query-devtools": "^5.85.9",
|
"@tanstack/react-query-devtools": "^5.90.2",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"arktype": "^2.1.20",
|
"arktype": "^2.1.23",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"isbot": "^5.1.27",
|
"isbot": "^5.1.31",
|
||||||
"lucide-react": "^0.544.0",
|
"lucide-react": "^0.544.0",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"prismjs": "^1.30.0",
|
"react": "^19.2.0",
|
||||||
"react": "^19.1.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-hook-form": "^7.63.0",
|
||||||
"react-hook-form": "^7.62.0",
|
"react-router": "^7.9.3",
|
||||||
"react-router": "^7.7.1",
|
"recharts": "3.2.1",
|
||||||
"recharts": "2.15.4",
|
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"yaml": "^2.8.1"
|
"yaml": "^2.8.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@react-router/dev": "^7.7.1",
|
"@react-router/dev": "^7.9.3",
|
||||||
"@tailwindcss/vite": "^4.1.4",
|
"@tailwindcss/vite": "^4.1.14",
|
||||||
"@types/node": "^20",
|
"@types/node": "^24.6.2",
|
||||||
"@types/prismjs": "^1.26.5",
|
"@types/react": "^19.2.0",
|
||||||
"@types/react": "^19.1.2",
|
"@types/react-dom": "^19.2.0",
|
||||||
"@types/react-dom": "^19.1.2",
|
"lightningcss": "^1.30.2",
|
||||||
"tailwindcss": "^4.1.4",
|
"tailwindcss": "^4.1.14",
|
||||||
"tw-animate-css": "^1.3.6",
|
"tinyglobby": "^0.2.15",
|
||||||
"typescript": "^5.8.3",
|
"tw-animate-css": "^1.4.0",
|
||||||
"vite": "npm:rolldown-vite@latest",
|
"typescript": "^5.9.3",
|
||||||
|
"vite": "^7.1.9",
|
||||||
"vite-bundle-analyzer": "^1.2.3",
|
"vite-bundle-analyzer": "^1.2.3",
|
||||||
"vite-tsconfig-paths": "^5.1.4"
|
"vite-tsconfig-paths": "^5.1.4"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,4 +3,7 @@ import type { Config } from "@react-router/dev/config";
|
|||||||
export default {
|
export default {
|
||||||
ssr: false,
|
ssr: false,
|
||||||
buildDirectory: "dist",
|
buildDirectory: "dist",
|
||||||
|
future: {
|
||||||
|
v8_middleware: true,
|
||||||
|
},
|
||||||
} satisfies Config;
|
} satisfies Config;
|
||||||
|
|||||||
@@ -9,5 +9,5 @@ await Bun.build({
|
|||||||
identifiers: true,
|
identifiers: true,
|
||||||
syntax: true,
|
syntax: true,
|
||||||
},
|
},
|
||||||
external: [],
|
external: ["ssh2"],
|
||||||
});
|
});
|
||||||
|
|||||||
17
apps/server/drizzle/0005_simple_alice.sql
Normal file
17
apps/server/drizzle/0005_simple_alice.sql
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
CREATE TABLE `sessions_table` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`user_id` integer NOT NULL,
|
||||||
|
`expires_at` integer NOT NULL,
|
||||||
|
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `users_table`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `users_table` (
|
||||||
|
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`username` text NOT NULL,
|
||||||
|
`password_hash` text NOT NULL,
|
||||||
|
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
|
||||||
|
`updated_at` integer DEFAULT (unixepoch()) NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `users_table_username_unique` ON `users_table` (`username`);
|
||||||
226
apps/server/drizzle/meta/0005_snapshot.json
Normal file
226
apps/server/drizzle/meta/0005_snapshot.json
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "75f0aac0-aa63-4577-bfb6-4638a008935f",
|
||||||
|
"prevId": "0b087a68-fbc6-4647-a6dc-e6322a3d4ee3",
|
||||||
|
"tables": {
|
||||||
|
"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
|
||||||
|
},
|
||||||
|
"path": {
|
||||||
|
"name": "path",
|
||||||
|
"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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,6 +36,13 @@
|
|||||||
"when": 1758961535488,
|
"when": 1758961535488,
|
||||||
"tag": "0004_wealthy_tomas",
|
"tag": "0004_wealthy_tomas",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 5,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1759416698274,
|
||||||
|
"tag": "0005_simple_alice",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -5,7 +5,8 @@
|
|||||||
"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"
|
"gen:migrations": "drizzle-kit generate",
|
||||||
|
"studio": "drizzle-kit studio"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hono/arktype-validator": "^2.0.1",
|
"@hono/arktype-validator": "^2.0.1",
|
||||||
@@ -15,7 +16,7 @@
|
|||||||
"arktype": "^2.1.20",
|
"arktype": "^2.1.20",
|
||||||
"dockerode": "^4.0.8",
|
"dockerode": "^4.0.8",
|
||||||
"dotenv": "^17.2.1",
|
"dotenv": "^17.2.1",
|
||||||
"drizzle-orm": "^0.44.4",
|
"drizzle-orm": "^0.44.6",
|
||||||
"hono": "^4.9.2",
|
"hono": "^4.9.2",
|
||||||
"hono-openapi": "^1.1.0",
|
"hono-openapi": "^1.1.0",
|
||||||
"http-errors-enhanced": "^3.0.2",
|
"http-errors-enhanced": "^3.0.2",
|
||||||
@@ -24,8 +25,9 @@
|
|||||||
"winston": "^3.17.0"
|
"winston": "^3.17.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "^1.2.20",
|
"@libsql/client": "^0.15.15",
|
||||||
|
"@types/bun": "^1.3.0",
|
||||||
"@types/dockerode": "^3.3.44",
|
"@types/dockerode": "^3.3.44",
|
||||||
"drizzle-kit": "^0.31.4"
|
"drizzle-kit": "^0.31.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ import "dotenv/config";
|
|||||||
|
|
||||||
const envSchema = type({
|
const envSchema = type({
|
||||||
NODE_ENV: type.enumerated("development", "production", "test").default("development"),
|
NODE_ENV: type.enumerated("development", "production", "test").default("development"),
|
||||||
|
SESSION_SECRET: "string?",
|
||||||
}).pipe((s) => ({
|
}).pipe((s) => ({
|
||||||
__prod__: s.NODE_ENV === "production",
|
__prod__: s.NODE_ENV === "production",
|
||||||
environment: s.NODE_ENV,
|
environment: s.NODE_ENV,
|
||||||
|
sessionSecret: s.SESSION_SECRET || "change-me-in-production-please",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const parseConfig = (env: unknown) => {
|
const parseConfig = (env: unknown) => {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
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/docker/volumes/ironmount";
|
||||||
export const DATABASE_URL = "/data/ironmount.db";
|
export const DATABASE_URL = "/data/ironmount.db";
|
||||||
|
export const RESTIC_PASS_FILE = "/data/secrets/restic.pass";
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
import type { BackendStatus, BackendType, volumeConfigSchema } from "@ironmount/schemas";
|
import type {
|
||||||
|
BackendStatus,
|
||||||
|
BackendType,
|
||||||
|
CompressionMode,
|
||||||
|
RepositoryBackend,
|
||||||
|
RepositoryStatus,
|
||||||
|
repositoryConfigSchema,
|
||||||
|
volumeConfigSchema,
|
||||||
|
} from "@ironmount/schemas";
|
||||||
import { sql } from "drizzle-orm";
|
import { sql } from "drizzle-orm";
|
||||||
import { int, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
import { int, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||||
|
|
||||||
@@ -17,3 +25,39 @@ export const volumesTable = sqliteTable("volumes_table", {
|
|||||||
});
|
});
|
||||||
|
|
||||||
export type Volume = typeof volumesTable.$inferSelect;
|
export type Volume = typeof volumesTable.$inferSelect;
|
||||||
|
|
||||||
|
export const usersTable = sqliteTable("users_table", {
|
||||||
|
id: int().primaryKey({ autoIncrement: true }),
|
||||||
|
username: text().notNull().unique(),
|
||||||
|
passwordHash: text("password_hash").notNull(),
|
||||||
|
createdAt: int("created_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`),
|
||||||
|
updatedAt: int("updated_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type User = typeof usersTable.$inferSelect;
|
||||||
|
|
||||||
|
export const sessionsTable = sqliteTable("sessions_table", {
|
||||||
|
id: text().primaryKey(),
|
||||||
|
userId: int("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => usersTable.id, { onDelete: "cascade" }),
|
||||||
|
expiresAt: int("expires_at", { mode: "timestamp" }).notNull(),
|
||||||
|
createdAt: int("created_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Session = typeof sessionsTable.$inferSelect;
|
||||||
|
|
||||||
|
export const repositoriesTable = sqliteTable("repositories_table", {
|
||||||
|
id: text().primaryKey(),
|
||||||
|
name: text().notNull().unique(),
|
||||||
|
backend: text().$type<RepositoryBackend>().notNull(),
|
||||||
|
config: text("config", { mode: "json" }).$type<typeof repositoryConfigSchema.inferOut>().notNull(),
|
||||||
|
compressionMode: text("compression_mode").$type<CompressionMode>().default("auto"),
|
||||||
|
status: text().$type<RepositoryStatus>().default("unknown"),
|
||||||
|
lastChecked: int("last_checked", { mode: "timestamp" }),
|
||||||
|
lastError: text("last_error"),
|
||||||
|
createdAt: int("created_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`),
|
||||||
|
updatedAt: int("updated_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Repository = typeof repositoriesTable.$inferSelect;
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { serveStatic } from "hono/bun";
|
|||||||
import { logger as honoLogger } from "hono/logger";
|
import { logger as honoLogger } from "hono/logger";
|
||||||
import { openAPIRouteHandler } from "hono-openapi";
|
import { openAPIRouteHandler } from "hono-openapi";
|
||||||
import { runDbMigrations } from "./db/db";
|
import { runDbMigrations } from "./db/db";
|
||||||
|
import { authController } from "./modules/auth/auth.controller";
|
||||||
|
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 { volumeController } from "./modules/volumes/volume.controller";
|
import { volumeController } from "./modules/volumes/volume.controller";
|
||||||
@@ -32,13 +34,14 @@ export const scalarDescriptor = Scalar({
|
|||||||
const driver = new Hono().use(honoLogger()).route("/", driverController);
|
const driver = new Hono().use(honoLogger()).route("/", driverController);
|
||||||
const app = new Hono()
|
const app = new Hono()
|
||||||
.use(honoLogger())
|
.use(honoLogger())
|
||||||
.get("*", serveStatic({ root: "./assets/frontend" }))
|
|
||||||
.get("healthcheck", (c) => c.json({ status: "ok" }))
|
.get("healthcheck", (c) => c.json({ status: "ok" }))
|
||||||
.basePath("/api/v1")
|
.route("/api/v1/auth", authController.basePath("/api/v1"))
|
||||||
.route("/volumes", volumeController);
|
.route("/api/v1/volumes", volumeController.use(requireAuth))
|
||||||
|
.get("/assets/*", serveStatic({ root: "./assets/frontend" }))
|
||||||
|
.get("*", serveStatic({ path: "./assets/frontend/index.html" }));
|
||||||
|
|
||||||
app.get("/openapi.json", generalDescriptor(app));
|
app.get("/api/v1/openapi.json", generalDescriptor(app));
|
||||||
app.get("/docs", scalarDescriptor);
|
app.get("/api/v1/docs", scalarDescriptor);
|
||||||
|
|
||||||
app.onError((err, c) => {
|
app.onError((err, c) => {
|
||||||
logger.error(`${c.req.url}: ${err.message}`);
|
logger.error(`${c.req.url}: ${err.message}`);
|
||||||
|
|||||||
91
apps/server/src/modules/auth/auth.controller.ts
Normal file
91
apps/server/src/modules/auth/auth.controller.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { validator } from "hono-openapi";
|
||||||
|
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import { deleteCookie, getCookie, setCookie } from "hono/cookie";
|
||||||
|
import {
|
||||||
|
getMeDto,
|
||||||
|
getStatusDto,
|
||||||
|
loginBodySchema,
|
||||||
|
loginDto,
|
||||||
|
logoutDto,
|
||||||
|
registerBodySchema,
|
||||||
|
registerDto,
|
||||||
|
} from "./auth.dto";
|
||||||
|
import { authService } from "./auth.service";
|
||||||
|
|
||||||
|
const COOKIE_NAME = "session_id";
|
||||||
|
const COOKIE_OPTIONS = {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
sameSite: "lax" as const,
|
||||||
|
path: "/",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const authController = new Hono()
|
||||||
|
.post("/register", registerDto, validator("json", registerBodySchema), async (c) => {
|
||||||
|
const body = c.req.valid("json");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { user, sessionId } = await authService.register(body.username, body.password);
|
||||||
|
|
||||||
|
setCookie(c, COOKIE_NAME, sessionId, {
|
||||||
|
...COOKIE_OPTIONS,
|
||||||
|
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);
|
||||||
|
} catch (error) {
|
||||||
|
return c.json({ message: error instanceof Error ? error.message : "Registration failed" }, 400);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.post("/login", loginDto, validator("json", loginBodySchema), async (c) => {
|
||||||
|
const body = c.req.valid("json");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { sessionId, user, expiresAt } = await authService.login(body.username, body.password);
|
||||||
|
|
||||||
|
setCookie(c, COOKIE_NAME, sessionId, {
|
||||||
|
...COOKIE_OPTIONS,
|
||||||
|
expires: expiresAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
message: "Login successful",
|
||||||
|
user: { id: user.id, username: user.username },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return c.json({ message: error instanceof Error ? error.message : "Login failed" }, 401);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.post("/logout", logoutDto, async (c) => {
|
||||||
|
const sessionId = getCookie(c, COOKIE_NAME);
|
||||||
|
|
||||||
|
if (sessionId) {
|
||||||
|
await authService.logout(sessionId);
|
||||||
|
deleteCookie(c, COOKIE_NAME, COOKIE_OPTIONS);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ message: "Logout successful" });
|
||||||
|
})
|
||||||
|
.get("/me", getMeDto, async (c) => {
|
||||||
|
const sessionId = getCookie(c, COOKIE_NAME);
|
||||||
|
|
||||||
|
if (!sessionId) {
|
||||||
|
return c.json({ message: "Not authenticated" }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await authService.verifySession(sessionId);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
deleteCookie(c, COOKIE_NAME, COOKIE_OPTIONS);
|
||||||
|
return c.json({ message: "Not authenticated" }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
user: session.user,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.get("/status", getStatusDto, async (c) => {
|
||||||
|
const hasUsers = await authService.hasUsers();
|
||||||
|
return c.json({ hasUsers });
|
||||||
|
});
|
||||||
117
apps/server/src/modules/auth/auth.dto.ts
Normal file
117
apps/server/src/modules/auth/auth.dto.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import { type } from "arktype";
|
||||||
|
import { describeRoute, resolver } from "hono-openapi";
|
||||||
|
|
||||||
|
// Validation schemas
|
||||||
|
export const loginBodySchema = type({
|
||||||
|
username: "string>0",
|
||||||
|
password: "string>7",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const registerBodySchema = type({
|
||||||
|
username: "string>2",
|
||||||
|
password: "string>7",
|
||||||
|
});
|
||||||
|
|
||||||
|
const loginResponseSchema = type({
|
||||||
|
message: "string",
|
||||||
|
user: type({
|
||||||
|
id: "string",
|
||||||
|
username: "string",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const loginDto = describeRoute({
|
||||||
|
description: "Login with username and password",
|
||||||
|
operationId: "login",
|
||||||
|
tags: ["Auth"],
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Login successful",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: resolver(loginResponseSchema),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
401: {
|
||||||
|
description: "Invalid credentials",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const registerDto = describeRoute({
|
||||||
|
description: "Register a new user",
|
||||||
|
operationId: "register",
|
||||||
|
tags: ["Auth"],
|
||||||
|
responses: {
|
||||||
|
201: {
|
||||||
|
description: "User created successfully",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: resolver(loginResponseSchema),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
400: {
|
||||||
|
description: "Invalid request or username already exists",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const logoutDto = describeRoute({
|
||||||
|
description: "Logout current user",
|
||||||
|
operationId: "logout",
|
||||||
|
tags: ["Auth"],
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Logout successful",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: resolver(type({ message: "string" })),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getMeDto = describeRoute({
|
||||||
|
description: "Get current authenticated user",
|
||||||
|
operationId: "getMe",
|
||||||
|
tags: ["Auth"],
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Current user information",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: resolver(loginResponseSchema),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
401: {
|
||||||
|
description: "Not authenticated",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const statusResponseSchema = type({
|
||||||
|
hasUsers: "boolean",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getStatusDto = describeRoute({
|
||||||
|
description: "Get authentication system status",
|
||||||
|
operationId: "getStatus",
|
||||||
|
tags: ["Auth"],
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Authentication system status",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: resolver(statusResponseSchema),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export type LoginBody = typeof loginBodySchema.infer;
|
||||||
|
export type RegisterBody = typeof registerBodySchema.infer;
|
||||||
63
apps/server/src/modules/auth/auth.middleware.ts
Normal file
63
apps/server/src/modules/auth/auth.middleware.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { deleteCookie, getCookie } from "hono/cookie";
|
||||||
|
import { createMiddleware } from "hono/factory";
|
||||||
|
import { authService } from "./auth.service";
|
||||||
|
|
||||||
|
const COOKIE_NAME = "session_id";
|
||||||
|
const COOKIE_OPTIONS = {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
sameSite: "lax" as const,
|
||||||
|
path: "/",
|
||||||
|
};
|
||||||
|
|
||||||
|
declare module "hono" {
|
||||||
|
interface ContextVariableMap {
|
||||||
|
user: {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware to require authentication
|
||||||
|
* Verifies the session cookie and attaches user to context
|
||||||
|
*/
|
||||||
|
export const requireAuth = createMiddleware(async (c, next) => {
|
||||||
|
const sessionId = getCookie(c, COOKIE_NAME);
|
||||||
|
|
||||||
|
if (!sessionId) {
|
||||||
|
return c.json({ message: "Authentication required" }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await authService.verifySession(sessionId);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
deleteCookie(c, COOKIE_NAME, COOKIE_OPTIONS);
|
||||||
|
return c.json({ message: "Invalid or expired session" }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
c.set("user", session.user);
|
||||||
|
|
||||||
|
await next();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware to optionally attach user if authenticated
|
||||||
|
* Does not block the request if not authenticated
|
||||||
|
*/
|
||||||
|
export const optionalAuth = createMiddleware(async (c, next) => {
|
||||||
|
const sessionId = getCookie(c, COOKIE_NAME);
|
||||||
|
|
||||||
|
if (sessionId) {
|
||||||
|
const session = await authService.verifySession(sessionId);
|
||||||
|
|
||||||
|
if (session) {
|
||||||
|
c.set("user", session.user);
|
||||||
|
} else {
|
||||||
|
deleteCookie(c, COOKIE_NAME, COOKIE_OPTIONS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await next();
|
||||||
|
});
|
||||||
139
apps/server/src/modules/auth/auth.service.ts
Normal file
139
apps/server/src/modules/auth/auth.service.ts
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { db } from "../../db/db";
|
||||||
|
import { sessionsTable, usersTable } from "../../db/schema";
|
||||||
|
import { logger } from "../../utils/logger";
|
||||||
|
|
||||||
|
const SESSION_DURATION = 1000 * 60 * 60 * 24 * 30; // 30 days
|
||||||
|
|
||||||
|
export class AuthService {
|
||||||
|
/**
|
||||||
|
* Register a new user with username and password
|
||||||
|
*/
|
||||||
|
async register(username: string, password: string) {
|
||||||
|
const [existingUser] = await db.select().from(usersTable);
|
||||||
|
|
||||||
|
if (existingUser) {
|
||||||
|
throw new Error("Admin user already exists");
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await Bun.password.hash(password, {
|
||||||
|
algorithm: "argon2id",
|
||||||
|
memoryCost: 19456,
|
||||||
|
timeCost: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [user] = await db.insert(usersTable).values({ username, passwordHash }).returning();
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new Error("User registration failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`User registered: ${username}`);
|
||||||
|
const sessionId = crypto.randomUUID();
|
||||||
|
const expiresAt = new Date(Date.now() + SESSION_DURATION);
|
||||||
|
|
||||||
|
await db.insert(sessionsTable).values({
|
||||||
|
id: sessionId,
|
||||||
|
userId: user.id,
|
||||||
|
expiresAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { user: { id: user.id, username: user.username, createdAt: user.createdAt }, sessionId };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Login user with username and password
|
||||||
|
*/
|
||||||
|
async login(username: string, password: string) {
|
||||||
|
const [user] = await db.select().from(usersTable).where(eq(usersTable.username, username));
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new Error("Invalid credentials");
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValid = await Bun.password.verify(password, user.passwordHash);
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
throw new Error("Invalid credentials");
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionId = crypto.randomUUID();
|
||||||
|
const expiresAt = new Date(Date.now() + SESSION_DURATION);
|
||||||
|
|
||||||
|
await db.insert(sessionsTable).values({
|
||||||
|
id: sessionId,
|
||||||
|
userId: user.id,
|
||||||
|
expiresAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`User logged in: ${username}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessionId,
|
||||||
|
user: { id: user.id, username: user.username },
|
||||||
|
expiresAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logout user by deleting their session
|
||||||
|
*/
|
||||||
|
async logout(sessionId: string) {
|
||||||
|
await db.delete(sessionsTable).where(eq(sessionsTable.id, sessionId));
|
||||||
|
logger.info(`User logged out: session ${sessionId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify a session and return the associated user
|
||||||
|
*/
|
||||||
|
async verifySession(sessionId: string) {
|
||||||
|
const [session] = await db
|
||||||
|
.select({
|
||||||
|
session: sessionsTable,
|
||||||
|
user: usersTable,
|
||||||
|
})
|
||||||
|
.from(sessionsTable)
|
||||||
|
.innerJoin(usersTable, eq(sessionsTable.userId, usersTable.id))
|
||||||
|
.where(eq(sessionsTable.id, sessionId));
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.session.expiresAt < new Date()) {
|
||||||
|
await db.delete(sessionsTable).where(eq(sessionsTable.id, sessionId));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
user: {
|
||||||
|
id: session.user.id,
|
||||||
|
username: session.user.username,
|
||||||
|
},
|
||||||
|
session: {
|
||||||
|
id: session.session.id,
|
||||||
|
expiresAt: session.session.expiresAt,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up expired sessions
|
||||||
|
*/
|
||||||
|
async cleanupExpiredSessions() {
|
||||||
|
const result = await db.delete(sessionsTable).where(eq(sessionsTable.expiresAt, new Date())).returning();
|
||||||
|
if (result.length > 0) {
|
||||||
|
logger.info(`Cleaned up ${result.length} expired sessions`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if any users exist in the system
|
||||||
|
*/
|
||||||
|
async hasUsers(): Promise<boolean> {
|
||||||
|
const [user] = await db.select({ id: usersTable.id }).from(usersTable).limit(1);
|
||||||
|
return !!user;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const authService = new AuthService();
|
||||||
@@ -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 };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { execFile as execFileCb } from "node:child_process";
|
|||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
import { OPERATION_TIMEOUT } from "../../../core/constants";
|
import { OPERATION_TIMEOUT } from "../../../core/constants";
|
||||||
import { logger } from "../../../utils/logger";
|
import { logger } from "../../../utils/logger";
|
||||||
|
import { toMessage } from "../../../utils/errors";
|
||||||
|
|
||||||
const execFile = promisify(execFileCb);
|
const execFile = promisify(execFileCb);
|
||||||
|
|
||||||
@@ -33,5 +34,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)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,8 +4,11 @@ import { db } from "../../db/db";
|
|||||||
import { volumesTable } from "../../db/schema";
|
import { volumesTable } from "../../db/schema";
|
||||||
import { logger } from "../../utils/logger";
|
import { logger } from "../../utils/logger";
|
||||||
import { volumeService } from "../volumes/volume.service";
|
import { volumeService } from "../volumes/volume.service";
|
||||||
|
import { restic } from "../../utils/restic";
|
||||||
|
|
||||||
export const startup = async () => {
|
export const startup = async () => {
|
||||||
|
await restic.ensurePassfile();
|
||||||
|
|
||||||
const volumes = await db.query.volumesTable.findMany({
|
const volumes = await db.query.volumesTable.findMany({
|
||||||
where: or(
|
where: or(
|
||||||
eq(volumesTable.status, "mounted"),
|
eq(volumesTable.status, "mounted"),
|
||||||
@@ -21,7 +24,7 @@ export const startup = async () => {
|
|||||||
existingTasks.forEach(async (task) => await task.destroy());
|
existingTasks.forEach(async (task) => await task.destroy());
|
||||||
|
|
||||||
schedule("* * * * *", async () => {
|
schedule("* * * * *", async () => {
|
||||||
logger.info("Running health check for all volumes...");
|
logger.debug("Running health check for all volumes...");
|
||||||
|
|
||||||
const volumes = await db.query.volumesTable.findMany({
|
const volumes = await db.query.volumesTable.findMany({
|
||||||
where: or(eq(volumesTable.status, "mounted"), eq(volumesTable.status, "error")),
|
where: or(eq(volumesTable.status, "mounted"), eq(volumesTable.status, "error")),
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -9,7 +9,9 @@ import {
|
|||||||
getVolumeDto,
|
getVolumeDto,
|
||||||
healthCheckDto,
|
healthCheckDto,
|
||||||
type ListContainersResponseDto,
|
type ListContainersResponseDto,
|
||||||
|
type ListFilesResponseDto,
|
||||||
type ListVolumesResponseDto,
|
type ListVolumesResponseDto,
|
||||||
|
listFilesDto,
|
||||||
listVolumesDto,
|
listVolumesDto,
|
||||||
mountVolumeDto,
|
mountVolumeDto,
|
||||||
testConnectionBody,
|
testConnectionBody,
|
||||||
@@ -118,4 +120,16 @@ export const volumeController = new Hono()
|
|||||||
const { error, status } = await volumeService.checkHealth(name);
|
const { error, status } = await volumeService.checkHealth(name);
|
||||||
|
|
||||||
return c.json({ error, status }, 200);
|
return c.json({ error, status }, 200);
|
||||||
|
})
|
||||||
|
.get("/:name/files", listFilesDto, async (c) => {
|
||||||
|
const { name } = c.req.param();
|
||||||
|
const subPath = c.req.query("path");
|
||||||
|
const result = await volumeService.listFiles(name, subPath);
|
||||||
|
|
||||||
|
const response = {
|
||||||
|
files: result.files,
|
||||||
|
path: result.path,
|
||||||
|
} satisfies ListFilesResponseDto;
|
||||||
|
|
||||||
|
return c.json(response, 200);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { volumeConfigSchemaNoUndefined } from "@ironmount/schemas";
|
import { volumeConfigSchema } from "@ironmount/schemas";
|
||||||
import { type } from "arktype";
|
import { type } from "arktype";
|
||||||
import { describeRoute, resolver } from "hono-openapi";
|
import { describeRoute, resolver } from "hono-openapi";
|
||||||
|
|
||||||
@@ -11,7 +11,7 @@ const volumeSchema = type({
|
|||||||
createdAt: "number",
|
createdAt: "number",
|
||||||
updatedAt: "number",
|
updatedAt: "number",
|
||||||
lastHealthCheck: "number",
|
lastHealthCheck: "number",
|
||||||
config: volumeConfigSchemaNoUndefined,
|
config: volumeConfigSchema,
|
||||||
autoRemount: "boolean",
|
autoRemount: "boolean",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -46,7 +46,7 @@ export const listVolumesDto = describeRoute({
|
|||||||
*/
|
*/
|
||||||
export const createVolumeBody = type({
|
export const createVolumeBody = type({
|
||||||
name: "string",
|
name: "string",
|
||||||
config: volumeConfigSchemaNoUndefined,
|
config: volumeConfigSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const createVolumeResponse = type({
|
export const createVolumeResponse = type({
|
||||||
@@ -135,7 +135,7 @@ export const getVolumeDto = describeRoute({
|
|||||||
*/
|
*/
|
||||||
export const updateVolumeBody = type({
|
export const updateVolumeBody = type({
|
||||||
autoRemount: "boolean?",
|
autoRemount: "boolean?",
|
||||||
config: volumeConfigSchemaNoUndefined.optional(),
|
config: volumeConfigSchema.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type UpdateVolumeBody = typeof updateVolumeBody.infer;
|
export type UpdateVolumeBody = typeof updateVolumeBody.infer;
|
||||||
@@ -170,7 +170,7 @@ export type UpdateVolumeResponseDto = typeof updateVolumeResponse.infer;
|
|||||||
* Test connection
|
* Test connection
|
||||||
*/
|
*/
|
||||||
export const testConnectionBody = type({
|
export const testConnectionBody = type({
|
||||||
config: volumeConfigSchemaNoUndefined,
|
config: volumeConfigSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const testConnectionResponse = type({
|
export const testConnectionResponse = type({
|
||||||
@@ -305,3 +305,50 @@ export const getContainersDto = describeRoute({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List files in a volume
|
||||||
|
*/
|
||||||
|
const fileEntrySchema = type({
|
||||||
|
name: "string",
|
||||||
|
path: "string",
|
||||||
|
type: type.enumerated("file", "directory"),
|
||||||
|
size: "number?",
|
||||||
|
modifiedAt: "number?",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const listFilesResponse = type({
|
||||||
|
files: fileEntrySchema.array(),
|
||||||
|
path: "string",
|
||||||
|
});
|
||||||
|
export type ListFilesResponseDto = typeof listFilesResponse.infer;
|
||||||
|
|
||||||
|
export const listFilesDto = describeRoute({
|
||||||
|
description: "List files in a volume directory",
|
||||||
|
operationId: "listFiles",
|
||||||
|
tags: ["Volumes"],
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
in: "query",
|
||||||
|
name: "path",
|
||||||
|
required: false,
|
||||||
|
schema: {
|
||||||
|
type: "string",
|
||||||
|
},
|
||||||
|
description: "Subdirectory path to list (relative to volume root)",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "List of files in the volume",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: resolver(listFilesResponse),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
404: {
|
||||||
|
description: "Volume not found",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@@ -253,6 +253,69 @@ const getContainersUsingVolume = async (name: string) => {
|
|||||||
return { containers: usingContainers };
|
return { containers: usingContainers };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const listFiles = async (name: string, subPath?: string) => {
|
||||||
|
const volume = await db.query.volumesTable.findFirst({
|
||||||
|
where: eq(volumesTable.name, name),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!volume) {
|
||||||
|
throw new NotFoundError("Volume not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (volume.status !== "mounted") {
|
||||||
|
throw new InternalServerError("Volume is not mounted");
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestedPath = subPath ? path.join(volume.path, subPath) : volume.path;
|
||||||
|
|
||||||
|
const normalizedPath = path.normalize(requestedPath);
|
||||||
|
if (!normalizedPath.startsWith(volume.path)) {
|
||||||
|
throw new InternalServerError("Invalid path");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const entries = await fs.readdir(normalizedPath, { withFileTypes: true });
|
||||||
|
|
||||||
|
const files = await Promise.all(
|
||||||
|
entries.map(async (entry) => {
|
||||||
|
const fullPath = path.join(normalizedPath, entry.name);
|
||||||
|
const relativePath = path.relative(volume.path, fullPath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stats = await fs.stat(fullPath);
|
||||||
|
return {
|
||||||
|
name: entry.name,
|
||||||
|
path: `/${relativePath}`,
|
||||||
|
type: entry.isDirectory() ? ("directory" as const) : ("file" as const),
|
||||||
|
size: entry.isFile() ? stats.size : undefined,
|
||||||
|
modifiedAt: stats.mtimeMs,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
name: entry.name,
|
||||||
|
path: `/${relativePath}`,
|
||||||
|
type: entry.isDirectory() ? ("directory" as const) : ("file" as const),
|
||||||
|
size: undefined,
|
||||||
|
modifiedAt: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
files: files.sort((a, b) => {
|
||||||
|
if (a.type !== b.type) {
|
||||||
|
return a.type === "directory" ? -1 : 1;
|
||||||
|
}
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
}),
|
||||||
|
path: subPath || "/",
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
throw new InternalServerError(`Failed to list files: ${toMessage(error)}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const volumeService = {
|
export const volumeService = {
|
||||||
listVolumes,
|
listVolumes,
|
||||||
createVolume,
|
createVolume,
|
||||||
@@ -264,4 +327,5 @@ export const volumeService = {
|
|||||||
unmountVolume,
|
unmountVolume,
|
||||||
checkHealth,
|
checkHealth,
|
||||||
getContainersUsingVolume,
|
getContainersUsingVolume,
|
||||||
|
listFiles,
|
||||||
};
|
};
|
||||||
|
|||||||
65
apps/server/src/utils/restic.ts
Normal file
65
apps/server/src/utils/restic.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { type } from "arktype";
|
||||||
|
import { $ } from "bun";
|
||||||
|
import { RESTIC_PASS_FILE } from "../core/constants";
|
||||||
|
import { logger } from "./logger";
|
||||||
|
|
||||||
|
const backupOutputSchema = type({
|
||||||
|
message_type: "'summary'",
|
||||||
|
files_new: "number",
|
||||||
|
files_changed: "number",
|
||||||
|
files_unmodified: "number",
|
||||||
|
dirs_new: "number",
|
||||||
|
dirs_changed: "number",
|
||||||
|
dirs_unmodified: "number",
|
||||||
|
data_blobs: "number",
|
||||||
|
tree_blobs: "number",
|
||||||
|
data_added: "number",
|
||||||
|
total_files_processed: "number",
|
||||||
|
total_bytes_processed: "number",
|
||||||
|
total_duration: "number",
|
||||||
|
snapshot_id: "string",
|
||||||
|
});
|
||||||
|
|
||||||
|
const ensurePassfile = async () => {
|
||||||
|
await fs.mkdir(path.dirname(RESTIC_PASS_FILE), { recursive: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.access(RESTIC_PASS_FILE);
|
||||||
|
} catch {
|
||||||
|
logger.info("Restic passfile not found, creating a new one...");
|
||||||
|
await fs.writeFile(RESTIC_PASS_FILE, crypto.randomBytes(32).toString("hex"), { mode: 0o600 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const init = async (name: string) => {
|
||||||
|
const res =
|
||||||
|
await $`restic init --repo /data/repositories/${name} --password-file /data/secrets/restic.pass --json`.nothrow();
|
||||||
|
};
|
||||||
|
|
||||||
|
const backup = async (repo: string, source: string) => {
|
||||||
|
const res =
|
||||||
|
await $`restic --repo /data/repositories/${repo} backup ${source} --password-file /data/secrets/restic.pass --json`.nothrow();
|
||||||
|
|
||||||
|
if (res.exitCode !== 0) {
|
||||||
|
logger.error(`Restic backup failed: ${res.stderr}`);
|
||||||
|
throw new Error(`Restic backup failed: ${res.stderr}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = backupOutputSchema(res.json());
|
||||||
|
|
||||||
|
if (result instanceof type.errors) {
|
||||||
|
logger.error(`Restic backup output validation failed: ${result}`);
|
||||||
|
throw new Error(`Restic backup output validation failed: ${result}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const restic = {
|
||||||
|
ensurePassfile,
|
||||||
|
init,
|
||||||
|
backup,
|
||||||
|
};
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "ironmount",
|
"name": "ironmount",
|
||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "bun@1.2.15",
|
"packageManager": "bun@1.3.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "turbo dev",
|
"dev": "turbo dev",
|
||||||
"tsc": "turbo run tsc",
|
"tsc": "turbo run tsc",
|
||||||
@@ -16,6 +16,9 @@
|
|||||||
],
|
],
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@hey-api/openapi-ts": "^0.80.17",
|
"@hey-api/openapi-ts": "^0.80.17",
|
||||||
"turbo": "^2.5.6"
|
"turbo": "^2.5.8"
|
||||||
}
|
},
|
||||||
|
"trustedDependencies": [
|
||||||
|
"@tailwindcss/oxide"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export const smbConfigSchema = type({
|
|||||||
username: "string",
|
username: "string",
|
||||||
password: "string",
|
password: "string",
|
||||||
vers: type("'1.0' | '2.0' | '2.1' | '3.0'").default("3.0"),
|
vers: type("'1.0' | '2.0' | '2.1' | '3.0'").default("3.0"),
|
||||||
domain: "string | undefined?",
|
domain: "string?",
|
||||||
port: type("string.integer").or(type("number")).to("1 <= number <= 65535").default(445),
|
port: type("string.integer").or(type("number")).to("1 <= number <= 65535").default(445),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -36,17 +36,12 @@ export const webdavConfigSchema = type({
|
|||||||
backend: "'webdav'",
|
backend: "'webdav'",
|
||||||
server: "string",
|
server: "string",
|
||||||
path: "string",
|
path: "string",
|
||||||
username: "string | undefined?",
|
username: "string?",
|
||||||
password: "string | undefined?",
|
password: "string?",
|
||||||
port: type("string.integer").or(type("number")).to("1 <= number <= 65536").default(80),
|
port: type("string.integer").or(type("number")).to("1 <= number <= 65536").default(80),
|
||||||
ssl: "boolean?",
|
ssl: "boolean?",
|
||||||
});
|
});
|
||||||
|
|
||||||
export const volumeConfigSchemaNoUndefined = nfsConfigSchema
|
|
||||||
.or(smbConfigSchema.omit("domain").and(type({ domain: "string?" })))
|
|
||||||
.or(webdavConfigSchema.omit("username", "password").and(type({ username: "string?", password: "string?" })))
|
|
||||||
.or(directoryConfigSchema);
|
|
||||||
|
|
||||||
export const volumeConfigSchema = nfsConfigSchema.or(smbConfigSchema).or(webdavConfigSchema).or(directoryConfigSchema);
|
export const volumeConfigSchema = nfsConfigSchema.or(smbConfigSchema).or(webdavConfigSchema).or(directoryConfigSchema);
|
||||||
|
|
||||||
export type BackendConfig = typeof volumeConfigSchema.infer;
|
export type BackendConfig = typeof volumeConfigSchema.infer;
|
||||||
@@ -58,3 +53,61 @@ export const BACKEND_STATUS = {
|
|||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type BackendStatus = keyof typeof BACKEND_STATUS;
|
export type BackendStatus = keyof typeof BACKEND_STATUS;
|
||||||
|
|
||||||
|
export const REPOSITORY_BACKENDS = {
|
||||||
|
local: "local",
|
||||||
|
sftp: "sftp",
|
||||||
|
s3: "s3",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type RepositoryBackend = keyof typeof REPOSITORY_BACKENDS;
|
||||||
|
|
||||||
|
export const localRepositoryConfigSchema = type({
|
||||||
|
backend: "'local'",
|
||||||
|
path: "string",
|
||||||
|
password: "string",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const sftpRepositoryConfigSchema = type({
|
||||||
|
backend: "'sftp'",
|
||||||
|
host: "string",
|
||||||
|
user: "string",
|
||||||
|
port: type("string.integer").or(type("number")).to("1 <= number <= 65535").default(22),
|
||||||
|
path: "string",
|
||||||
|
sftpPassword: "string?",
|
||||||
|
sftpPrivateKey: "string?",
|
||||||
|
sftpCommand: "string?",
|
||||||
|
sftpArgs: "string?",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const s3RepositoryConfigSchema = type({
|
||||||
|
backend: "'s3'",
|
||||||
|
endpoint: "string",
|
||||||
|
bucket: "string",
|
||||||
|
accessKeyId: "string",
|
||||||
|
secretAccessKey: "string",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const repositoryConfigSchema = localRepositoryConfigSchema
|
||||||
|
.or(sftpRepositoryConfigSchema)
|
||||||
|
.or(s3RepositoryConfigSchema);
|
||||||
|
|
||||||
|
export type RepositoryConfig = typeof repositoryConfigSchema.infer;
|
||||||
|
|
||||||
|
export const COMPRESSION_MODES = {
|
||||||
|
off: "off",
|
||||||
|
auto: "auto",
|
||||||
|
fastest: "fastest",
|
||||||
|
better: "better",
|
||||||
|
max: "max",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type CompressionMode = keyof typeof COMPRESSION_MODES;
|
||||||
|
|
||||||
|
export const REPOSITORY_STATUS = {
|
||||||
|
healthy: "healthy",
|
||||||
|
error: "error",
|
||||||
|
unknown: "unknown",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type RepositoryStatus = keyof typeof REPOSITORY_STATUS;
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 363 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.2 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 407 KiB |
Reference in New Issue
Block a user