Compare commits

...

10 Commits

Author SHA1 Message Date
Nicolas Meienberger
6d3d3c38f9 fix: docker build 2025-11-13 20:15:56 +01:00
Nico
95a0d44b45 refactor: unify backend and frontend servers (#3)
* refactor: unify backend and frontend servers

* refactor: correct paths for openapi & drizzle

* refactor: move api-client to client

* fix: drizzle paths

* chore: fix linting issues

* fix: form reset issue
2025-11-13 20:11:46 +01:00
Nicolas Meienberger
8d7e50508d refactor: system-info hook 2025-11-11 21:48:42 +01:00
Nicolas Meienberger
52e38a6242 refactor: rclone system capability 2025-11-11 21:44:04 +01:00
Nicolas Meienberger
36b0282d18 feat(frontend): rclone repositories config 2025-11-11 21:31:10 +01:00
Nicolas Meienberger
8f9873148a feat(repositories): rclone backends 2025-11-11 20:42:44 +01:00
Nicolas Meienberger
a1cc89c66e fix: ensure caching in file explorers 2025-11-11 18:01:54 +01:00
Nicolas Meienberger
ff7f6ffad9 feat(repositories): azure blob storage 2025-11-10 21:07:12 +01:00
Nicolas Meienberger
e98c0af8ca feat(repositories): add google cloud storage support 2025-11-10 21:04:08 +01:00
Nicolas Meienberger
d31fa8d464 chore: small improvements 2025-11-10 21:03:37 +01:00
242 changed files with 6054 additions and 6111 deletions

View File

@@ -12,12 +12,9 @@
!**/build.ts !**/build.ts
!**/components.json !**/components.json
!apps/**/src/** !src/**
!apps/**/drizzle/** !app/**
!apps/**/app/** !public/**
!apps/**/public/**
!packages/**/src/**
# License files and attributions # License files and attributions
!LICENSE !LICENSE

48
.gitignore vendored
View File

@@ -1,47 +1,11 @@
# If you prefer the allow list template instead of the deny list, see community template: .DS_Store
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore /node_modules/
#
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with `go test -c` # React Router
*.test /.react-router/
/build/
/dist/
# Code coverage profiles and other test artifacts
*.out
coverage.*
*.coverprofile
profile.cov
# Dependency directories (remove the comment below to include it)
# vendor/
# Go workspace file
go.work
go.work.sum
# env file
.env .env
# Editor/IDE
# .idea/
# .vscode/
ironmount
out/
*.db
tmp/
node_modules/
.env*
.turbo .turbo
mutagen.yml.lock
data/
CLAUDE.md CLAUDE.md

View File

@@ -21,11 +21,17 @@ RUN apk add --no-cache curl bzip2
RUN echo "Building for ${TARGETARCH}" RUN echo "Building for ${TARGETARCH}"
RUN if [ "${TARGETARCH}" = "arm64" ]; then \ RUN if [ "${TARGETARCH}" = "arm64" ]; then \
curl -L -o restic.bz2 "https://github.com/restic/restic/releases/download/v$RESTIC_VERSION/restic_$RESTIC_VERSION"_linux_arm64.bz2; \ curl -L -o restic.bz2 "https://github.com/restic/restic/releases/download/v$RESTIC_VERSION/restic_$RESTIC_VERSION"_linux_arm64.bz2; \
curl -O https://downloads.rclone.org/rclone-current-linux-arm64.zip; \
unzip rclone-current-linux-arm64.zip; \
elif [ "${TARGETARCH}" = "amd64" ]; then \ elif [ "${TARGETARCH}" = "amd64" ]; then \
curl -L -o restic.bz2 "https://github.com/restic/restic/releases/download/v$RESTIC_VERSION/restic_$RESTIC_VERSION"_linux_amd64.bz2; \ curl -L -o restic.bz2 "https://github.com/restic/restic/releases/download/v$RESTIC_VERSION/restic_$RESTIC_VERSION"_linux_amd64.bz2; \
curl -O https://downloads.rclone.org/rclone-current-linux-amd64.zip; \
unzip rclone-current-linux-amd64.zip; \
fi fi
RUN bzip2 -d restic.bz2 && chmod +x restic RUN bzip2 -d restic.bz2 && chmod +x restic
RUN mv rclone-*-linux-*/rclone /deps/rclone && chmod +x /deps/rclone
# ------------------------------ # ------------------------------
# DEVELOPMENT # DEVELOPMENT
@@ -37,16 +43,14 @@ ENV NODE_ENV="development"
WORKDIR /app WORKDIR /app
COPY --from=deps /deps/restic /usr/local/bin/restic COPY --from=deps /deps/restic /usr/local/bin/restic
COPY --from=deps /deps/rclone /usr/local/bin/rclone
COPY ./package.json ./bun.lock ./ COPY ./package.json ./bun.lock ./
COPY ./packages/schemas/package.json ./packages/schemas/package.json
COPY ./apps/client/package.json ./apps/client/package.json
COPY ./apps/server/package.json ./apps/server/package.json
RUN bun install --frozen-lockfile RUN bun install --frozen-lockfile
COPY . . COPY . .
EXPOSE 3000 EXPOSE 4096
CMD ["bun", "run", "dev"] CMD ["bun", "run", "dev"]
@@ -58,11 +62,6 @@ FROM oven/bun:${BUN_VERSION} AS builder
WORKDIR /app WORKDIR /app
COPY ./package.json ./bun.lock ./ COPY ./package.json ./bun.lock ./
COPY ./packages/schemas/package.json ./packages/schemas/package.json
COPY ./apps/client/package.json ./apps/client/package.json
COPY ./apps/server/package.json ./apps/server/package.json
RUN bun install --frozen-lockfile RUN bun install --frozen-lockfile
COPY . . COPY . .
@@ -75,15 +74,21 @@ ENV NODE_ENV="production"
WORKDIR /app WORKDIR /app
COPY --from=builder /app/package.json ./
RUN bun install --production --frozen-lockfile
COPY --from=deps /deps/restic /usr/local/bin/restic COPY --from=deps /deps/restic /usr/local/bin/restic
COPY --from=builder /app/apps/server/dist ./ COPY --from=deps /deps/rclone /usr/local/bin/rclone
COPY --from=builder /app/apps/server/drizzle ./assets/migrations COPY --from=builder /app/dist/client ./dist/client
COPY --from=builder /app/apps/client/dist/client ./assets/frontend COPY --from=builder /app/dist/server ./dist/server
COPY --from=builder /app/app/drizzle ./assets/migrations
# Include third-party licenses and attribution # Include third-party licenses and attribution
COPY ./LICENSES ./LICENSES COPY ./LICENSES ./LICENSES
COPY ./NOTICES.md ./NOTICES.md COPY ./NOTICES.md ./NOTICES.md
COPY ./LICENSE ./LICENSE.md COPY ./LICENSE ./LICENSE.md
CMD ["bun", "./index.js"] EXPOSE 4096
CMD ["bun", "run", "start"]

View File

@@ -94,11 +94,69 @@ Now, when adding a new volume in the Ironmount web interface, you can select "Di
## Creating a repository ## Creating a repository
A repository is where your backups will be securely stored encrypted. Ironmount currently supports S3-compatible storage backends and local directories for storing your backup repositories. A repository is where your backups will be securely stored encrypted. Ironmount supports multiple storage backends for your backup repositories:
- **Local directories** - Store backups on local disk at `/var/lib/ironmount/repositories/<repository-name>`
- **S3-compatible storage** - Amazon S3, MinIO, Wasabi, DigitalOcean Spaces, etc.
- **Google Cloud Storage** - Google's cloud storage service
- **Azure Blob Storage** - Microsoft Azure storage
- **rclone remotes** - 40+ cloud storage providers via rclone (see below)
Repositories are optimized for storage efficiency and data integrity, leveraging Restic's deduplication and encryption features. Repositories are optimized for storage efficiency and data integrity, leveraging Restic's deduplication and encryption features.
To create a repository, navigate to the "Repositories" section in the web interface and click on "Create repository". Fill in the required details such as repository name, type, and connection settings. If you choose a local directory as the repository type, your backups will be stored at `/var/lib/ironmount/repositories/<repository-name>`. To create a repository, navigate to the "Repositories" section in the web interface and click on "Create repository". Fill in the required details such as repository name, type, and connection settings.
### Using rclone for cloud storage
Ironmount can use [rclone](https://rclone.org/) to support 40+ cloud storage providers including Google Drive, Dropbox, OneDrive, Box, pCloud, Mega, and many more. This gives you the flexibility to store your backups on virtually any cloud storage service.
**Setup instructions:**
1. **Install rclone on your host system** (if not already installed):
```bash
curl https://rclone.org/install.sh | sudo bash
```
2. **Configure your cloud storage remote** using rclone's interactive config:
```bash
rclone config
```
Follow the prompts to set up your cloud storage provider. For OAuth providers (Google Drive, Dropbox, etc.), rclone will guide you through the authentication flow.
3. **Verify your remote is configured**:
```bash
rclone listremotes
```
4. **Mount the rclone config into the Ironmount container** by updating your `docker-compose.yml`:
```diff
services:
ironmount:
image: ghcr.io/nicotsx/ironmount:v0.6
container_name: ironmount
restart: unless-stopped
privileged: true
ports:
- "4096:4096"
devices:
- /dev/fuse:/dev/fuse
volumes:
- /var/lib/ironmount:/var/lib/ironmount
+ - ~/.config/rclone:/root/.config/rclone
```
5. **Restart the Ironmount container**:
```bash
docker compose down
docker compose up -d
```
6. **Create a repository** in Ironmount:
- Select "rclone" as the repository type
- Choose your configured remote from the dropdown
- Specify the path within your remote (e.g., `backups/ironmount`)
For a complete list of supported providers, see the [rclone documentation](https://rclone.org/).
## Your first backup job ## Your first backup job

View File

@@ -1,7 +1,7 @@
// This file is auto-generated by @hey-api/openapi-ts // This file is auto-generated by @hey-api/openapi-ts
import type { ClientOptions } from "./types.gen"; import { type ClientOptions, type Config, createClient, createConfig } from "./client";
import { type Config, type ClientOptions as DefaultClientOptions, createClient, createConfig } from "./client"; import type { ClientOptions as ClientOptions2 } from "./types.gen";
/** /**
* The `createClientConfig()` function will be called on client initialization * The `createClientConfig()` function will be called on client initialization
@@ -11,12 +11,12 @@ import { type Config, type ClientOptions as DefaultClientOptions, createClient,
* `setConfig()`. This is useful for example if you're using Next.js * `setConfig()`. This is useful for example if you're using Next.js
* to ensure your client always has the correct values. * to ensure your client always has the correct values.
*/ */
export type CreateClientConfig<T extends DefaultClientOptions = ClientOptions> = ( export type CreateClientConfig<T extends ClientOptions = ClientOptions2> = (
override?: Config<DefaultClientOptions & T>, override?: Config<ClientOptions & T>,
) => Config<Required<DefaultClientOptions> & T>; ) => Config<Required<ClientOptions> & T>;
export const client = createClient( export const client = createClient(
createConfig<ClientOptions>({ createConfig<ClientOptions2>({
baseUrl: "http://192.168.2.42:4096", baseUrl: "http://192.168.2.42:4096",
}), }),
); );

View File

@@ -1,6 +1,9 @@
// This file is auto-generated by @hey-api/openapi-ts // This file is auto-generated by @hey-api/openapi-ts
import type { Client, Config, ResolvedRequestOptions } from "./types.gen"; import { createSseClient } from "../core/serverSentEvents.gen";
import type { HttpMethod } from "../core/types.gen";
import { getValidRequestBody } from "../core/utils.gen";
import type { Client, Config, RequestOptions, ResolvedRequestOptions } from "./types.gen";
import { import {
buildUrl, buildUrl,
createConfig, createConfig,
@@ -28,7 +31,7 @@ export const createClient = (config: Config = {}): Client => {
const interceptors = createInterceptors<Request, Response, unknown, ResolvedRequestOptions>(); const interceptors = createInterceptors<Request, Response, unknown, ResolvedRequestOptions>();
const request: Client["request"] = async (options) => { const beforeRequest = async (options: RequestOptions) => {
const opts = { const opts = {
..._config, ..._config,
...options, ...options,
@@ -48,25 +51,32 @@ export const createClient = (config: Config = {}): Client => {
await opts.requestValidator(opts); await opts.requestValidator(opts);
} }
if (opts.body && opts.bodySerializer) { if (opts.body !== undefined && opts.bodySerializer) {
opts.serializedBody = opts.bodySerializer(opts.body); opts.serializedBody = opts.bodySerializer(opts.body);
} }
// remove Content-Type header if body is empty to avoid sending invalid requests // remove Content-Type header if body is empty to avoid sending invalid requests
if (opts.serializedBody === undefined || opts.serializedBody === "") { if (opts.body === undefined || opts.serializedBody === "") {
opts.headers.delete("Content-Type"); opts.headers.delete("Content-Type");
} }
const url = buildUrl(opts); const url = buildUrl(opts);
return { opts, url };
};
const request: Client["request"] = async (options) => {
// @ts-expect-error
const { opts, url } = await beforeRequest(options);
const requestInit: ReqInit = { const requestInit: ReqInit = {
redirect: "follow", redirect: "follow",
...opts, ...opts,
body: opts.serializedBody, body: getValidRequestBody(opts),
}; };
let request = new Request(url, requestInit); let request = new Request(url, requestInit);
for (const fn of interceptors.request._fns) { for (const fn of interceptors.request.fns) {
if (fn) { if (fn) {
request = await fn(request, opts); request = await fn(request, opts);
} }
@@ -75,9 +85,37 @@ export const createClient = (config: Config = {}): Client => {
// fetch must be assigned here, otherwise it would throw the error: // fetch must be assigned here, otherwise it would throw the error:
// TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation // TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation
const _fetch = opts.fetch!; const _fetch = opts.fetch!;
let response = await _fetch(request); let response: Response;
for (const fn of interceptors.response._fns) { try {
response = await _fetch(request);
} catch (error) {
// Handle fetch exceptions (AbortError, network errors, etc.)
let finalError = error;
for (const fn of interceptors.error.fns) {
if (fn) {
finalError = (await fn(error, undefined as any, request, opts)) as unknown;
}
}
finalError = finalError || ({} as unknown);
if (opts.throwOnError) {
throw finalError;
}
// Return error response
return opts.responseStyle === "data"
? undefined
: {
error: finalError,
request,
response: undefined as any,
};
}
for (const fn of interceptors.response.fns) {
if (fn) { if (fn) {
response = await fn(response, request, opts); response = await fn(response, request, opts);
} }
@@ -89,18 +127,36 @@ export const createClient = (config: Config = {}): Client => {
}; };
if (response.ok) { if (response.ok) {
const parseAs =
(opts.parseAs === "auto" ? getParseAs(response.headers.get("Content-Type")) : opts.parseAs) ?? "json";
if (response.status === 204 || response.headers.get("Content-Length") === "0") { if (response.status === 204 || response.headers.get("Content-Length") === "0") {
let emptyData: any;
switch (parseAs) {
case "arrayBuffer":
case "blob":
case "text":
emptyData = await response[parseAs]();
break;
case "formData":
emptyData = new FormData();
break;
case "stream":
emptyData = response.body;
break;
case "json":
default:
emptyData = {};
break;
}
return opts.responseStyle === "data" return opts.responseStyle === "data"
? {} ? emptyData
: { : {
data: {}, data: emptyData,
...result, ...result,
}; };
} }
const parseAs =
(opts.parseAs === "auto" ? getParseAs(response.headers.get("Content-Type")) : opts.parseAs) ?? "json";
let data: any; let data: any;
switch (parseAs) { switch (parseAs) {
case "arrayBuffer": case "arrayBuffer":
@@ -149,7 +205,7 @@ export const createClient = (config: Config = {}): Client => {
const error = jsonError ?? textError; const error = jsonError ?? textError;
let finalError = error; let finalError = error;
for (const fn of interceptors.error._fns) { for (const fn of interceptors.error.fns) {
if (fn) { if (fn) {
finalError = (await fn(error, response, request, opts)) as string; finalError = (await fn(error, response, request, opts)) as string;
} }
@@ -170,20 +226,53 @@ export const createClient = (config: Config = {}): Client => {
}; };
}; };
const makeMethodFn = (method: Uppercase<HttpMethod>) => (options: RequestOptions) => request({ ...options, method });
const makeSseFn = (method: Uppercase<HttpMethod>) => async (options: RequestOptions) => {
const { opts, url } = await beforeRequest(options);
return createSseClient({
...opts,
body: opts.body as BodyInit | null | undefined,
headers: opts.headers as unknown as Record<string, string>,
method,
onRequest: async (url, init) => {
let request = new Request(url, init);
for (const fn of interceptors.request.fns) {
if (fn) {
request = await fn(request, opts);
}
}
return request;
},
url,
});
};
return { return {
buildUrl, buildUrl,
connect: (options) => request({ ...options, method: "CONNECT" }), connect: makeMethodFn("CONNECT"),
delete: (options) => request({ ...options, method: "DELETE" }), delete: makeMethodFn("DELETE"),
get: (options) => request({ ...options, method: "GET" }), get: makeMethodFn("GET"),
getConfig, getConfig,
head: (options) => request({ ...options, method: "HEAD" }), head: makeMethodFn("HEAD"),
interceptors, interceptors,
options: (options) => request({ ...options, method: "OPTIONS" }), options: makeMethodFn("OPTIONS"),
patch: (options) => request({ ...options, method: "PATCH" }), patch: makeMethodFn("PATCH"),
post: (options) => request({ ...options, method: "POST" }), post: makeMethodFn("POST"),
put: (options) => request({ ...options, method: "PUT" }), put: makeMethodFn("PUT"),
request, request,
setConfig, setConfig,
trace: (options) => request({ ...options, method: "TRACE" }), sse: {
}; connect: makeSseFn("CONNECT"),
delete: makeSseFn("DELETE"),
get: makeSseFn("GET"),
head: makeSseFn("HEAD"),
options: makeSseFn("OPTIONS"),
patch: makeSseFn("PATCH"),
post: makeSseFn("POST"),
put: makeSseFn("PUT"),
trace: makeSseFn("TRACE"),
},
trace: makeMethodFn("TRACE"),
} as Client;
}; };

View File

@@ -8,6 +8,7 @@ export {
urlSearchParamsBodySerializer, urlSearchParamsBodySerializer,
} from "../core/bodySerializer.gen"; } from "../core/bodySerializer.gen";
export { buildClientParams } from "../core/params.gen"; export { buildClientParams } from "../core/params.gen";
export { serializeQueryKeyValue } from "../core/queryKeySerializer.gen";
export { createClient } from "./client.gen"; export { createClient } from "./client.gen";
export type { export type {
Client, Client,
@@ -15,7 +16,6 @@ export type {
Config, Config,
CreateClientConfig, CreateClientConfig,
Options, Options,
OptionsLegacyParser,
RequestOptions, RequestOptions,
RequestResult, RequestResult,
ResolvedRequestOptions, ResolvedRequestOptions,

View File

@@ -1,6 +1,7 @@
// This file is auto-generated by @hey-api/openapi-ts // This file is auto-generated by @hey-api/openapi-ts
import type { Auth } from "../core/auth.gen"; import type { Auth } from "../core/auth.gen";
import type { ServerSentEventsOptions, ServerSentEventsResult } from "../core/serverSentEvents.gen";
import type { Client as CoreClient, Config as CoreConfig } from "../core/types.gen"; import type { Client as CoreClient, Config as CoreConfig } from "../core/types.gen";
import type { Middleware } from "./utils.gen"; import type { Middleware } from "./utils.gen";
@@ -19,7 +20,7 @@ export interface Config<T extends ClientOptions = ClientOptions>
* *
* @default globalThis.fetch * @default globalThis.fetch
*/ */
fetch?: (request: Request) => ReturnType<typeof fetch>; fetch?: typeof fetch;
/** /**
* Please don't use the Fetch client for Next.js applications. The `next` * Please don't use the Fetch client for Next.js applications. The `next`
* options won't have any effect. * options won't have any effect.
@@ -51,13 +52,18 @@ export interface Config<T extends ClientOptions = ClientOptions>
} }
export interface RequestOptions< export interface RequestOptions<
TData = unknown,
TResponseStyle extends ResponseStyle = "fields", TResponseStyle extends ResponseStyle = "fields",
ThrowOnError extends boolean = boolean, ThrowOnError extends boolean = boolean,
Url extends string = string, Url extends string = string,
> extends Config<{ > extends Config<{
responseStyle: TResponseStyle; responseStyle: TResponseStyle;
throwOnError: ThrowOnError; throwOnError: ThrowOnError;
}> { }>,
Pick<
ServerSentEventsOptions<TData>,
"onSseError" | "onSseEvent" | "sseDefaultRetryDelay" | "sseMaxRetryAttempts" | "sseMaxRetryDelay"
> {
/** /**
* Any body that you want to add to your request. * Any body that you want to add to your request.
* *
@@ -77,7 +83,7 @@ export interface ResolvedRequestOptions<
TResponseStyle extends ResponseStyle = "fields", TResponseStyle extends ResponseStyle = "fields",
ThrowOnError extends boolean = boolean, ThrowOnError extends boolean = boolean,
Url extends string = string, Url extends string = string,
> extends RequestOptions<TResponseStyle, ThrowOnError, Url> { > extends RequestOptions<unknown, TResponseStyle, ThrowOnError, Url> {
serializedBody?: string; serializedBody?: string;
} }
@@ -128,17 +134,26 @@ type MethodFn = <
ThrowOnError extends boolean = false, ThrowOnError extends boolean = false,
TResponseStyle extends ResponseStyle = "fields", TResponseStyle extends ResponseStyle = "fields",
>( >(
options: Omit<RequestOptions<TResponseStyle, ThrowOnError>, "method">, options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, "method">,
) => RequestResult<TData, TError, ThrowOnError, TResponseStyle>; ) => RequestResult<TData, TError, ThrowOnError, TResponseStyle>;
type SseFn = <
TData = unknown,
TError = unknown,
ThrowOnError extends boolean = false,
TResponseStyle extends ResponseStyle = "fields",
>(
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, "method">,
) => Promise<ServerSentEventsResult<TData, TError>>;
type RequestFn = < type RequestFn = <
TData = unknown, TData = unknown,
TError = unknown, TError = unknown,
ThrowOnError extends boolean = false, ThrowOnError extends boolean = false,
TResponseStyle extends ResponseStyle = "fields", TResponseStyle extends ResponseStyle = "fields",
>( >(
options: Omit<RequestOptions<TResponseStyle, ThrowOnError>, "method"> & options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, "method"> &
Pick<Required<RequestOptions<TResponseStyle, ThrowOnError>>, "method">, Pick<Required<RequestOptions<TData, TResponseStyle, ThrowOnError>>, "method">,
) => RequestResult<TData, TError, ThrowOnError, TResponseStyle>; ) => RequestResult<TData, TError, ThrowOnError, TResponseStyle>;
type BuildUrlFn = < type BuildUrlFn = <
@@ -149,10 +164,10 @@ type BuildUrlFn = <
url: string; url: string;
}, },
>( >(
options: Pick<TData, "url"> & Options<TData>, options: TData & Options<TData>,
) => string; ) => string;
export type Client = CoreClient<RequestFn, Config, MethodFn, BuildUrlFn> & { export type Client = CoreClient<RequestFn, Config, MethodFn, BuildUrlFn, SseFn> & {
interceptors: Middleware<Request, Response, unknown, ResolvedRequestOptions>; interceptors: Middleware<Request, Response, unknown, ResolvedRequestOptions>;
}; };
@@ -181,21 +196,7 @@ type OmitKeys<T, K> = Pick<T, Exclude<keyof T, K>>;
export type Options< export type Options<
TData extends TDataShape = TDataShape, TData extends TDataShape = TDataShape,
ThrowOnError extends boolean = boolean, ThrowOnError extends boolean = boolean,
TResponse = unknown,
TResponseStyle extends ResponseStyle = "fields", TResponseStyle extends ResponseStyle = "fields",
> = OmitKeys<RequestOptions<TResponseStyle, ThrowOnError>, "body" | "path" | "query" | "url"> & Omit<TData, "url">; > = OmitKeys<RequestOptions<TResponse, TResponseStyle, ThrowOnError>, "body" | "path" | "query" | "url"> &
([TData] extends [never] ? unknown : Omit<TData, "url">);
export type OptionsLegacyParser<
TData = unknown,
ThrowOnError extends boolean = boolean,
TResponseStyle extends ResponseStyle = "fields",
> = TData extends { body?: any }
? TData extends { headers?: any }
? OmitKeys<RequestOptions<TResponseStyle, ThrowOnError>, "body" | "headers" | "url"> & TData
: OmitKeys<RequestOptions<TResponseStyle, ThrowOnError>, "body" | "url"> &
TData &
Pick<RequestOptions<TResponseStyle, ThrowOnError>, "headers">
: TData extends { headers?: any }
? OmitKeys<RequestOptions<TResponseStyle, ThrowOnError>, "headers" | "url"> &
TData &
Pick<RequestOptions<TResponseStyle, ThrowOnError>, "body">
: OmitKeys<RequestOptions<TResponseStyle, ThrowOnError>, "url"> & TData;

View File

@@ -1,88 +1,13 @@
// This file is auto-generated by @hey-api/openapi-ts // This file is auto-generated by @hey-api/openapi-ts
import { getAuthToken } from "../core/auth.gen"; import { getAuthToken } from "../core/auth.gen";
import type { QuerySerializer, QuerySerializerOptions } from "../core/bodySerializer.gen"; import type { QuerySerializerOptions } from "../core/bodySerializer.gen";
import { jsonBodySerializer } from "../core/bodySerializer.gen"; import { jsonBodySerializer } from "../core/bodySerializer.gen";
import { serializeArrayParam, serializeObjectParam, serializePrimitiveParam } from "../core/pathSerializer.gen"; import { serializeArrayParam, serializeObjectParam, serializePrimitiveParam } from "../core/pathSerializer.gen";
import { getUrl } from "../core/utils.gen";
import type { Client, ClientOptions, Config, RequestOptions } from "./types.gen"; import type { Client, ClientOptions, Config, RequestOptions } from "./types.gen";
interface PathSerializer { export const createQuerySerializer = <T = unknown>({ parameters = {}, ...args }: QuerySerializerOptions = {}) => {
path: Record<string, unknown>;
url: string;
}
const PATH_PARAM_RE = /\{[^{}]+\}/g;
type ArrayStyle = "form" | "spaceDelimited" | "pipeDelimited";
type MatrixStyle = "label" | "matrix" | "simple";
type ArraySeparatorStyle = ArrayStyle | MatrixStyle;
const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => {
let url = _url;
const matches = _url.match(PATH_PARAM_RE);
if (matches) {
for (const match of matches) {
let explode = false;
let name = match.substring(1, match.length - 1);
let style: ArraySeparatorStyle = "simple";
if (name.endsWith("*")) {
explode = true;
name = name.substring(0, name.length - 1);
}
if (name.startsWith(".")) {
name = name.substring(1);
style = "label";
} else if (name.startsWith(";")) {
name = name.substring(1);
style = "matrix";
}
const value = path[name];
if (value === undefined || value === null) {
continue;
}
if (Array.isArray(value)) {
url = url.replace(match, serializeArrayParam({ explode, name, style, value }));
continue;
}
if (typeof value === "object") {
url = url.replace(
match,
serializeObjectParam({
explode,
name,
style,
value: value as Record<string, unknown>,
valueOnly: true,
}),
);
continue;
}
if (style === "matrix") {
url = url.replace(
match,
`;${serializePrimitiveParam({
name,
value: value as string,
})}`,
);
continue;
}
const replaceValue = encodeURIComponent(style === "label" ? `.${value as string}` : (value as string));
url = url.replace(match, replaceValue);
}
}
return url;
};
export const createQuerySerializer = <T = unknown>({ allowReserved, array, object }: QuerySerializerOptions = {}) => {
const querySerializer = (queryParams: T) => { const querySerializer = (queryParams: T) => {
const search: string[] = []; const search: string[] = [];
if (queryParams && typeof queryParams === "object") { if (queryParams && typeof queryParams === "object") {
@@ -93,29 +18,31 @@ export const createQuerySerializer = <T = unknown>({ allowReserved, array, objec
continue; continue;
} }
const options = parameters[name] || args;
if (Array.isArray(value)) { if (Array.isArray(value)) {
const serializedArray = serializeArrayParam({ const serializedArray = serializeArrayParam({
allowReserved, allowReserved: options.allowReserved,
explode: true, explode: true,
name, name,
style: "form", style: "form",
value, value,
...array, ...options.array,
}); });
if (serializedArray) search.push(serializedArray); if (serializedArray) search.push(serializedArray);
} else if (typeof value === "object") { } else if (typeof value === "object") {
const serializedObject = serializeObjectParam({ const serializedObject = serializeObjectParam({
allowReserved, allowReserved: options.allowReserved,
explode: true, explode: true,
name, name,
style: "deepObject", style: "deepObject",
value: value as Record<string, unknown>, value: value as Record<string, unknown>,
...object, ...options.object,
}); });
if (serializedObject) search.push(serializedObject); if (serializedObject) search.push(serializedObject);
} else { } else {
const serializedPrimitive = serializePrimitiveParam({ const serializedPrimitive = serializePrimitiveParam({
allowReserved, allowReserved: options.allowReserved,
name, name,
value: value as string, value: value as string,
}); });
@@ -216,8 +143,8 @@ export const setAuthParams = async ({
} }
}; };
export const buildUrl: Client["buildUrl"] = (options) => { export const buildUrl: Client["buildUrl"] = (options) =>
const url = getUrl({ getUrl({
baseUrl: options.baseUrl as string, baseUrl: options.baseUrl as string,
path: options.path, path: options.path,
query: options.query, query: options.query,
@@ -227,36 +154,6 @@ export const buildUrl: Client["buildUrl"] = (options) => {
: createQuerySerializer(options.querySerializer), : createQuerySerializer(options.querySerializer),
url: options.url, url: options.url,
}); });
return url;
};
export const getUrl = ({
baseUrl,
path,
query,
querySerializer,
url: _url,
}: {
baseUrl?: string;
path?: Record<string, unknown>;
query?: Record<string, unknown>;
querySerializer: QuerySerializer;
url: string;
}) => {
const pathUrl = _url.startsWith("/") ? _url : `/${_url}`;
let url = (baseUrl ?? "") + pathUrl;
if (path) {
url = defaultPathSerializer({ path, url });
}
let search = query ? querySerializer(query) : "";
if (search.startsWith("?")) {
search = search.substring(1);
}
if (search) {
url += `?${search}`;
}
return url;
};
export const mergeConfigs = (a: Config, b: Config): Config => { export const mergeConfigs = (a: Config, b: Config): Config => {
const config = { ...a, ...b }; const config = { ...a, ...b };
@@ -267,14 +164,22 @@ export const mergeConfigs = (a: Config, b: Config): Config => {
return config; return config;
}; };
const headersEntries = (headers: Headers): Array<[string, string]> => {
const entries: Array<[string, string]> = [];
headers.forEach((value, key) => {
entries.push([key, value]);
});
return entries;
};
export const mergeHeaders = (...headers: Array<Required<Config>["headers"] | undefined>): Headers => { export const mergeHeaders = (...headers: Array<Required<Config>["headers"] | undefined>): Headers => {
const mergedHeaders = new Headers(); const mergedHeaders = new Headers();
for (const header of headers) { for (const header of headers) {
if (!header || typeof header !== "object") { if (!header) {
continue; continue;
} }
const iterator = header instanceof Headers ? header.entries() : Object.entries(header); const iterator = header instanceof Headers ? headersEntries(header) : Object.entries(header);
for (const [key, value] of iterator) { for (const [key, value] of iterator) {
if (value === null) { if (value === null) {
@@ -305,61 +210,53 @@ type ReqInterceptor<Req, Options> = (request: Req, options: Options) => Req | Pr
type ResInterceptor<Res, Req, Options> = (response: Res, request: Req, options: Options) => Res | Promise<Res>; type ResInterceptor<Res, Req, Options> = (response: Res, request: Req, options: Options) => Res | Promise<Res>;
class Interceptors<Interceptor> { class Interceptors<Interceptor> {
_fns: (Interceptor | null)[]; fns: Array<Interceptor | null> = [];
constructor() { clear(): void {
this._fns = []; this.fns = [];
} }
clear() { eject(id: number | Interceptor): void {
this._fns = []; const index = this.getInterceptorIndex(id);
if (this.fns[index]) {
this.fns[index] = null;
}
}
exists(id: number | Interceptor): boolean {
const index = this.getInterceptorIndex(id);
return Boolean(this.fns[index]);
} }
getInterceptorIndex(id: number | Interceptor): number { getInterceptorIndex(id: number | Interceptor): number {
if (typeof id === "number") { if (typeof id === "number") {
return this._fns[id] ? id : -1; return this.fns[id] ? id : -1;
} else {
return this._fns.indexOf(id);
} }
} return this.fns.indexOf(id);
exists(id: number | Interceptor) {
const index = this.getInterceptorIndex(id);
return !!this._fns[index];
} }
eject(id: number | Interceptor) { update(id: number | Interceptor, fn: Interceptor): number | Interceptor | false {
const index = this.getInterceptorIndex(id); const index = this.getInterceptorIndex(id);
if (this._fns[index]) { if (this.fns[index]) {
this._fns[index] = null; this.fns[index] = fn;
}
}
update(id: number | Interceptor, fn: Interceptor) {
const index = this.getInterceptorIndex(id);
if (this._fns[index]) {
this._fns[index] = fn;
return id; return id;
} else {
return false;
} }
return false;
} }
use(fn: Interceptor) { use(fn: Interceptor): number {
this._fns = [...this._fns, fn]; this.fns.push(fn);
return this._fns.length - 1; return this.fns.length - 1;
} }
} }
// `createInterceptors()` response, meant for external use as it does not
// expose internals
export interface Middleware<Req, Res, Err, Options> { export interface Middleware<Req, Res, Err, Options> {
error: Pick<Interceptors<ErrInterceptor<Err, Res, Req, Options>>, "eject" | "use">; error: Interceptors<ErrInterceptor<Err, Res, Req, Options>>;
request: Pick<Interceptors<ReqInterceptor<Req, Options>>, "eject" | "use">; request: Interceptors<ReqInterceptor<Req, Options>>;
response: Pick<Interceptors<ResInterceptor<Res, Req, Options>>, "eject" | "use">; response: Interceptors<ResInterceptor<Res, Req, Options>>;
} }
// do not add `Middleware` as return type so we can use _fns internally export const createInterceptors = <Req, Res, Err, Options>(): Middleware<Req, Res, Err, Options> => ({
export const createInterceptors = <Req, Res, Err, Options>() => ({
error: new Interceptors<ErrInterceptor<Err, Res, Req, Options>>(), error: new Interceptors<ErrInterceptor<Err, Res, Req, Options>>(),
request: new Interceptors<ReqInterceptor<Req, Options>>(), request: new Interceptors<ReqInterceptor<Req, Options>>(),
response: new Interceptors<ResInterceptor<Res, Req, Options>>(), response: new Interceptors<ResInterceptor<Res, Req, Options>>(),

View File

@@ -6,11 +6,19 @@ export type QuerySerializer = (query: Record<string, unknown>) => string;
export type BodySerializer = (body: any) => any; export type BodySerializer = (body: any) => any;
export interface QuerySerializerOptions { type QuerySerializerOptionsObject = {
allowReserved?: boolean; allowReserved?: boolean;
array?: SerializerOptions<ArrayStyle>; array?: Partial<SerializerOptions<ArrayStyle>>;
object?: SerializerOptions<ObjectStyle>; object?: Partial<SerializerOptions<ObjectStyle>>;
} };
export type QuerySerializerOptions = QuerySerializerOptionsObject & {
/**
* Per-parameter serialization overrides. When provided, these settings
* override the global array/object settings for specific parameter names.
*/
parameters?: Record<string, QuerySerializerOptionsObject>;
};
const serializeFormDataPair = (data: FormData, key: string, value: unknown): void => { const serializeFormDataPair = (data: FormData, key: string, value: unknown): void => {
if (typeof value === "string" || value instanceof Blob) { if (typeof value === "string" || value instanceof Blob) {

View File

@@ -22,6 +22,17 @@ export type Field =
*/ */
key?: string; key?: string;
map?: string; map?: string;
}
| {
/**
* Field name. This is the name we want the user to see and use.
*/
key: string;
/**
* Field mapped name. This is the name we want to use in the request.
* If `in` is omitted, `map` aliases `key` to the transport layer.
*/
map: Slot;
}; };
export interface Fields { export interface Fields {
@@ -41,10 +52,14 @@ const extraPrefixes = Object.entries(extraPrefixesMap);
type KeyMap = Map< type KeyMap = Map<
string, string,
{ | {
in: Slot; in: Slot;
map?: string; map?: string;
} }
| {
in?: never;
map: Slot;
}
>; >;
const buildKeyMap = (fields: FieldsConfig, map?: KeyMap): KeyMap => { const buildKeyMap = (fields: FieldsConfig, map?: KeyMap): KeyMap => {
@@ -60,6 +75,10 @@ const buildKeyMap = (fields: FieldsConfig, map?: KeyMap): KeyMap => {
map: config.map, map: config.map,
}); });
} }
} else if ("key" in config) {
map.set(config.key, {
map: config.map,
});
} else if (config.args) { } else if (config.args) {
buildKeyMap(config.args, map); buildKeyMap(config.args, map);
} }
@@ -108,7 +127,9 @@ export const buildClientParams = (args: ReadonlyArray<unknown>, fields: FieldsCo
if (config.key) { if (config.key) {
const field = map.get(config.key)!; const field = map.get(config.key)!;
const name = field.map || config.key; const name = field.map || config.key;
(params[field.in] as Record<string, unknown>)[name] = arg; if (field.in) {
(params[field.in] as Record<string, unknown>)[name] = arg;
}
} else { } else {
params.body = arg; params.body = arg;
} }
@@ -117,16 +138,20 @@ export const buildClientParams = (args: ReadonlyArray<unknown>, fields: FieldsCo
const field = map.get(key); const field = map.get(key);
if (field) { if (field) {
const name = field.map || key; if (field.in) {
(params[field.in] as Record<string, unknown>)[name] = value; const name = field.map || key;
(params[field.in] as Record<string, unknown>)[name] = value;
} else {
params[field.map] = value;
}
} else { } else {
const extra = extraPrefixes.find(([prefix]) => key.startsWith(prefix)); const extra = extraPrefixes.find(([prefix]) => key.startsWith(prefix));
if (extra) { if (extra) {
const [prefix, slot] = extra; const [prefix, slot] = extra;
(params[slot] as Record<string, unknown>)[key.slice(prefix.length)] = value; (params[slot] as Record<string, unknown>)[key.slice(prefix.length)] = value;
} else { } else if ("allowExtra" in config && config.allowExtra) {
for (const [slot, allowed] of Object.entries(config.allowExtra ?? {})) { for (const [slot, allowed] of Object.entries(config.allowExtra)) {
if (allowed) { if (allowed) {
(params[slot as Slot] as Record<string, unknown>)[key] = value; (params[slot as Slot] as Record<string, unknown>)[key] = value;
break; break;

View File

@@ -0,0 +1,111 @@
// This file is auto-generated by @hey-api/openapi-ts
/**
* JSON-friendly union that mirrors what Pinia Colada can hash.
*/
export type JsonValue = null | string | number | boolean | JsonValue[] | { [key: string]: JsonValue };
/**
* Replacer that converts non-JSON values (bigint, Date, etc.) to safe substitutes.
*/
export const queryKeyJsonReplacer = (_key: string, value: unknown) => {
if (value === undefined || typeof value === "function" || typeof value === "symbol") {
return undefined;
}
if (typeof value === "bigint") {
return value.toString();
}
if (value instanceof Date) {
return value.toISOString();
}
return value;
};
/**
* Safely stringifies a value and parses it back into a JsonValue.
*/
export const stringifyToJsonValue = (input: unknown): JsonValue | undefined => {
try {
const json = JSON.stringify(input, queryKeyJsonReplacer);
if (json === undefined) {
return undefined;
}
return JSON.parse(json) as JsonValue;
} catch {
return undefined;
}
};
/**
* Detects plain objects (including objects with a null prototype).
*/
const isPlainObject = (value: unknown): value is Record<string, unknown> => {
if (value === null || typeof value !== "object") {
return false;
}
const prototype = Object.getPrototypeOf(value as object);
return prototype === Object.prototype || prototype === null;
};
/**
* Turns URLSearchParams into a sorted JSON object for deterministic keys.
*/
const serializeSearchParams = (params: URLSearchParams): JsonValue => {
const entries = Array.from(params.entries()).sort(([a], [b]) => a.localeCompare(b));
const result: Record<string, JsonValue> = {};
for (const [key, value] of entries) {
const existing = result[key];
if (existing === undefined) {
result[key] = value;
continue;
}
if (Array.isArray(existing)) {
(existing as string[]).push(value);
} else {
result[key] = [existing, value];
}
}
return result;
};
/**
* Normalizes any accepted value into a JSON-friendly shape for query keys.
*/
export const serializeQueryKeyValue = (value: unknown): JsonValue | undefined => {
if (value === null) {
return null;
}
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
return value;
}
if (value === undefined || typeof value === "function" || typeof value === "symbol") {
return undefined;
}
if (typeof value === "bigint") {
return value.toString();
}
if (value instanceof Date) {
return value.toISOString();
}
if (Array.isArray(value)) {
return stringifyToJsonValue(value);
}
if (typeof URLSearchParams !== "undefined" && value instanceof URLSearchParams) {
return serializeSearchParams(value);
}
if (isPlainObject(value)) {
return stringifyToJsonValue(value);
}
return undefined;
};

View File

@@ -0,0 +1,237 @@
// This file is auto-generated by @hey-api/openapi-ts
import type { Config } from "./types.gen";
export type ServerSentEventsOptions<TData = unknown> = Omit<RequestInit, "method"> &
Pick<Config, "method" | "responseTransformer" | "responseValidator"> & {
/**
* Fetch API implementation. You can use this option to provide a custom
* fetch instance.
*
* @default globalThis.fetch
*/
fetch?: typeof fetch;
/**
* Implementing clients can call request interceptors inside this hook.
*/
onRequest?: (url: string, init: RequestInit) => Promise<Request>;
/**
* Callback invoked when a network or parsing error occurs during streaming.
*
* This option applies only if the endpoint returns a stream of events.
*
* @param error The error that occurred.
*/
onSseError?: (error: unknown) => void;
/**
* Callback invoked when an event is streamed from the server.
*
* This option applies only if the endpoint returns a stream of events.
*
* @param event Event streamed from the server.
* @returns Nothing (void).
*/
onSseEvent?: (event: StreamEvent<TData>) => void;
serializedBody?: RequestInit["body"];
/**
* Default retry delay in milliseconds.
*
* This option applies only if the endpoint returns a stream of events.
*
* @default 3000
*/
sseDefaultRetryDelay?: number;
/**
* Maximum number of retry attempts before giving up.
*/
sseMaxRetryAttempts?: number;
/**
* Maximum retry delay in milliseconds.
*
* Applies only when exponential backoff is used.
*
* This option applies only if the endpoint returns a stream of events.
*
* @default 30000
*/
sseMaxRetryDelay?: number;
/**
* Optional sleep function for retry backoff.
*
* Defaults to using `setTimeout`.
*/
sseSleepFn?: (ms: number) => Promise<void>;
url: string;
};
export interface StreamEvent<TData = unknown> {
data: TData;
event?: string;
id?: string;
retry?: number;
}
export type ServerSentEventsResult<TData = unknown, TReturn = void, TNext = unknown> = {
stream: AsyncGenerator<TData extends Record<string, unknown> ? TData[keyof TData] : TData, TReturn, TNext>;
};
export const createSseClient = <TData = unknown>({
onRequest,
onSseError,
onSseEvent,
responseTransformer,
responseValidator,
sseDefaultRetryDelay,
sseMaxRetryAttempts,
sseMaxRetryDelay,
sseSleepFn,
url,
...options
}: ServerSentEventsOptions): ServerSentEventsResult<TData> => {
let lastEventId: string | undefined;
const sleep = sseSleepFn ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms)));
const createStream = async function* () {
let retryDelay: number = sseDefaultRetryDelay ?? 3000;
let attempt = 0;
const signal = options.signal ?? new AbortController().signal;
while (true) {
if (signal.aborted) break;
attempt++;
const headers =
options.headers instanceof Headers
? options.headers
: new Headers(options.headers as Record<string, string> | undefined);
if (lastEventId !== undefined) {
headers.set("Last-Event-ID", lastEventId);
}
try {
const requestInit: RequestInit = {
redirect: "follow",
...options,
body: options.serializedBody,
headers,
signal,
};
let request = new Request(url, requestInit);
if (onRequest) {
request = await onRequest(url, requestInit);
}
// fetch must be assigned here, otherwise it would throw the error:
// TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation
const _fetch = options.fetch ?? globalThis.fetch;
const response = await _fetch(request);
if (!response.ok) throw new Error(`SSE failed: ${response.status} ${response.statusText}`);
if (!response.body) throw new Error("No body in SSE response");
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
let buffer = "";
const abortHandler = () => {
try {
reader.cancel();
} catch {
// noop
}
};
signal.addEventListener("abort", abortHandler);
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += value;
const chunks = buffer.split("\n\n");
buffer = chunks.pop() ?? "";
for (const chunk of chunks) {
const lines = chunk.split("\n");
const dataLines: Array<string> = [];
let eventName: string | undefined;
for (const line of lines) {
if (line.startsWith("data:")) {
dataLines.push(line.replace(/^data:\s*/, ""));
} else if (line.startsWith("event:")) {
eventName = line.replace(/^event:\s*/, "");
} else if (line.startsWith("id:")) {
lastEventId = line.replace(/^id:\s*/, "");
} else if (line.startsWith("retry:")) {
const parsed = Number.parseInt(line.replace(/^retry:\s*/, ""), 10);
if (!Number.isNaN(parsed)) {
retryDelay = parsed;
}
}
}
let data: unknown;
let parsedJson = false;
if (dataLines.length) {
const rawData = dataLines.join("\n");
try {
data = JSON.parse(rawData);
parsedJson = true;
} catch {
data = rawData;
}
}
if (parsedJson) {
if (responseValidator) {
await responseValidator(data);
}
if (responseTransformer) {
data = await responseTransformer(data);
}
}
onSseEvent?.({
data,
event: eventName,
id: lastEventId,
retry: retryDelay,
});
if (dataLines.length) {
yield data as any;
}
}
}
} finally {
signal.removeEventListener("abort", abortHandler);
reader.releaseLock();
}
break; // exit loop on normal completion
} catch (error) {
// connection failed or aborted; retry after delay
onSseError?.(error);
if (sseMaxRetryAttempts !== undefined && attempt >= sseMaxRetryAttempts) {
break; // stop after firing error
}
// exponential backoff: double retry each attempt, cap at 30s
const backoff = Math.min(retryDelay * 2 ** (attempt - 1), sseMaxRetryDelay ?? 30000);
await sleep(backoff);
}
}
};
const stream = createStream();
return { stream };
};

View File

@@ -3,24 +3,19 @@
import type { Auth, AuthToken } from "./auth.gen"; import type { Auth, AuthToken } from "./auth.gen";
import type { BodySerializer, QuerySerializer, QuerySerializerOptions } from "./bodySerializer.gen"; import type { BodySerializer, QuerySerializer, QuerySerializerOptions } from "./bodySerializer.gen";
export interface Client<RequestFn = never, Config = unknown, MethodFn = never, BuildUrlFn = never> { export type HttpMethod = "connect" | "delete" | "get" | "head" | "options" | "patch" | "post" | "put" | "trace";
export type Client<RequestFn = never, Config = unknown, MethodFn = never, BuildUrlFn = never, SseFn = never> = {
/** /**
* Returns the final request URL. * Returns the final request URL.
*/ */
buildUrl: BuildUrlFn; buildUrl: BuildUrlFn;
connect: MethodFn;
delete: MethodFn;
get: MethodFn;
getConfig: () => Config; getConfig: () => Config;
head: MethodFn;
options: MethodFn;
patch: MethodFn;
post: MethodFn;
put: MethodFn;
request: RequestFn; request: RequestFn;
setConfig: (config: Config) => Config; setConfig: (config: Config) => Config;
trace: MethodFn; } & {
} [K in HttpMethod]: MethodFn;
} & ([SseFn] extends [never] ? { sse?: never } : { sse: { [K in HttpMethod]: SseFn } });
export interface Config { export interface Config {
/** /**
@@ -47,7 +42,7 @@ export interface Config {
* *
* {@link https://developer.mozilla.org/docs/Web/API/fetch#method See more} * {@link https://developer.mozilla.org/docs/Web/API/fetch#method See more}
*/ */
method?: "CONNECT" | "DELETE" | "GET" | "HEAD" | "OPTIONS" | "PATCH" | "POST" | "PUT" | "TRACE"; method?: Uppercase<HttpMethod>;
/** /**
* A function for serializing request query parameters. By default, arrays * A function for serializing request query parameters. By default, arrays
* will be exploded in form style, objects will be exploded in deepObject * will be exploded in form style, objects will be exploded in deepObject

View File

@@ -0,0 +1,137 @@
// This file is auto-generated by @hey-api/openapi-ts
import type { BodySerializer, QuerySerializer } from "./bodySerializer.gen";
import {
type ArraySeparatorStyle,
serializeArrayParam,
serializeObjectParam,
serializePrimitiveParam,
} from "./pathSerializer.gen";
export interface PathSerializer {
path: Record<string, unknown>;
url: string;
}
export const PATH_PARAM_RE = /\{[^{}]+\}/g;
export const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => {
let url = _url;
const matches = _url.match(PATH_PARAM_RE);
if (matches) {
for (const match of matches) {
let explode = false;
let name = match.substring(1, match.length - 1);
let style: ArraySeparatorStyle = "simple";
if (name.endsWith("*")) {
explode = true;
name = name.substring(0, name.length - 1);
}
if (name.startsWith(".")) {
name = name.substring(1);
style = "label";
} else if (name.startsWith(";")) {
name = name.substring(1);
style = "matrix";
}
const value = path[name];
if (value === undefined || value === null) {
continue;
}
if (Array.isArray(value)) {
url = url.replace(match, serializeArrayParam({ explode, name, style, value }));
continue;
}
if (typeof value === "object") {
url = url.replace(
match,
serializeObjectParam({
explode,
name,
style,
value: value as Record<string, unknown>,
valueOnly: true,
}),
);
continue;
}
if (style === "matrix") {
url = url.replace(
match,
`;${serializePrimitiveParam({
name,
value: value as string,
})}`,
);
continue;
}
const replaceValue = encodeURIComponent(style === "label" ? `.${value as string}` : (value as string));
url = url.replace(match, replaceValue);
}
}
return url;
};
export const getUrl = ({
baseUrl,
path,
query,
querySerializer,
url: _url,
}: {
baseUrl?: string;
path?: Record<string, unknown>;
query?: Record<string, unknown>;
querySerializer: QuerySerializer;
url: string;
}) => {
const pathUrl = _url.startsWith("/") ? _url : `/${_url}`;
let url = (baseUrl ?? "") + pathUrl;
if (path) {
url = defaultPathSerializer({ path, url });
}
let search = query ? querySerializer(query) : "";
if (search.startsWith("?")) {
search = search.substring(1);
}
if (search) {
url += `?${search}`;
}
return url;
};
export function getValidRequestBody(options: {
body?: unknown;
bodySerializer?: BodySerializer | null;
serializedBody?: unknown;
}) {
const hasBody = options.body !== undefined;
const isSerializedBody = hasBody && options.bodySerializer;
if (isSerializedBody) {
if ("serializedBody" in options) {
const hasSerializedBody = options.serializedBody !== undefined && options.serializedBody !== "";
return hasSerializedBody ? options.serializedBody : null;
}
// not all clients implement a serializedBody property (i.e. client-axios)
return options.body !== "" ? options.body : null;
}
// plain/text body
if (hasBody) {
return options.body;
}
// no body was provided
return undefined;
}

View File

@@ -1,3 +1,4 @@
// This file is auto-generated by @hey-api/openapi-ts // This file is auto-generated by @hey-api/openapi-ts
export * from "./types.gen";
export type * from "./types.gen";
export * from "./sdk.gen"; export * from "./sdk.gen";

View File

@@ -1,90 +1,92 @@
// This file is auto-generated by @hey-api/openapi-ts // This file is auto-generated by @hey-api/openapi-ts
import type { Options as ClientOptions, TDataShape, Client } from "./client"; import type { Client, Options as Options2, TDataShape } from "./client";
import { client } from "./client.gen";
import type { import type {
RegisterData, BrowseFilesystemData,
RegisterResponses, BrowseFilesystemResponses,
ChangePasswordData,
ChangePasswordResponses,
CreateBackupScheduleData,
CreateBackupScheduleResponses,
CreateRepositoryData,
CreateRepositoryResponses,
CreateVolumeData,
CreateVolumeResponses,
DeleteBackupScheduleData,
DeleteBackupScheduleResponses,
DeleteRepositoryData,
DeleteRepositoryResponses,
DeleteVolumeData,
DeleteVolumeResponses,
DoctorRepositoryData,
DoctorRepositoryResponses,
DownloadResticPasswordData,
DownloadResticPasswordResponses,
GetBackupScheduleData,
GetBackupScheduleForVolumeData,
GetBackupScheduleForVolumeResponses,
GetBackupScheduleResponses,
GetContainersUsingVolumeData,
GetContainersUsingVolumeErrors,
GetContainersUsingVolumeResponses,
GetMeData,
GetMeResponses,
GetRepositoryData,
GetRepositoryResponses,
GetSnapshotDetailsData,
GetSnapshotDetailsResponses,
GetStatusData,
GetStatusResponses,
GetSystemInfoData,
GetSystemInfoResponses,
GetVolumeData,
GetVolumeErrors,
GetVolumeResponses,
HealthCheckVolumeData,
HealthCheckVolumeErrors,
HealthCheckVolumeResponses,
ListBackupSchedulesData,
ListBackupSchedulesResponses,
ListFilesData,
ListFilesResponses,
ListRcloneRemotesData,
ListRcloneRemotesResponses,
ListRepositoriesData,
ListRepositoriesResponses,
ListSnapshotFilesData,
ListSnapshotFilesResponses,
ListSnapshotsData,
ListSnapshotsResponses,
ListVolumesData,
ListVolumesResponses,
LoginData, LoginData,
LoginResponses, LoginResponses,
LogoutData, LogoutData,
LogoutResponses, LogoutResponses,
GetMeData,
GetMeResponses,
GetStatusData,
GetStatusResponses,
ChangePasswordData,
ChangePasswordResponses,
ListVolumesData,
ListVolumesResponses,
CreateVolumeData,
CreateVolumeResponses,
TestConnectionData,
TestConnectionResponses,
DeleteVolumeData,
DeleteVolumeResponses,
GetVolumeData,
GetVolumeResponses,
GetVolumeErrors,
UpdateVolumeData,
UpdateVolumeResponses,
UpdateVolumeErrors,
GetContainersUsingVolumeData,
GetContainersUsingVolumeResponses,
GetContainersUsingVolumeErrors,
MountVolumeData, MountVolumeData,
MountVolumeResponses, MountVolumeResponses,
UnmountVolumeData, RegisterData,
UnmountVolumeResponses, RegisterResponses,
HealthCheckVolumeData,
HealthCheckVolumeResponses,
HealthCheckVolumeErrors,
ListFilesData,
ListFilesResponses,
BrowseFilesystemData,
BrowseFilesystemResponses,
ListRepositoriesData,
ListRepositoriesResponses,
CreateRepositoryData,
CreateRepositoryResponses,
DeleteRepositoryData,
DeleteRepositoryResponses,
GetRepositoryData,
GetRepositoryResponses,
ListSnapshotsData,
ListSnapshotsResponses,
GetSnapshotDetailsData,
GetSnapshotDetailsResponses,
ListSnapshotFilesData,
ListSnapshotFilesResponses,
RestoreSnapshotData, RestoreSnapshotData,
RestoreSnapshotResponses, RestoreSnapshotResponses,
DoctorRepositoryData,
DoctorRepositoryResponses,
ListBackupSchedulesData,
ListBackupSchedulesResponses,
CreateBackupScheduleData,
CreateBackupScheduleResponses,
DeleteBackupScheduleData,
DeleteBackupScheduleResponses,
GetBackupScheduleData,
GetBackupScheduleResponses,
UpdateBackupScheduleData,
UpdateBackupScheduleResponses,
GetBackupScheduleForVolumeData,
GetBackupScheduleForVolumeResponses,
RunBackupNowData, RunBackupNowData,
RunBackupNowResponses, RunBackupNowResponses,
StopBackupData, StopBackupData,
StopBackupResponses,
StopBackupErrors, StopBackupErrors,
GetSystemInfoData, StopBackupResponses,
GetSystemInfoResponses, TestConnectionData,
DownloadResticPasswordData, TestConnectionResponses,
DownloadResticPasswordResponses, UnmountVolumeData,
UnmountVolumeResponses,
UpdateBackupScheduleData,
UpdateBackupScheduleResponses,
UpdateVolumeData,
UpdateVolumeErrors,
UpdateVolumeResponses,
} from "./types.gen"; } from "./types.gen";
import { client as _heyApiClient } from "./client.gen";
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = ClientOptions< export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<
TData, TData,
ThrowOnError ThrowOnError
> & { > & {
@@ -105,7 +107,7 @@ export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends
* Register a new user * Register a new user
*/ */
export const register = <ThrowOnError extends boolean = false>(options?: Options<RegisterData, ThrowOnError>) => { export const register = <ThrowOnError extends boolean = false>(options?: Options<RegisterData, ThrowOnError>) => {
return (options?.client ?? _heyApiClient).post<RegisterResponses, unknown, ThrowOnError>({ return (options?.client ?? client).post<RegisterResponses, unknown, ThrowOnError>({
url: "/api/v1/auth/register", url: "/api/v1/auth/register",
...options, ...options,
headers: { headers: {
@@ -119,7 +121,7 @@ export const register = <ThrowOnError extends boolean = false>(options?: Options
* Login with username and password * Login with username and password
*/ */
export const login = <ThrowOnError extends boolean = false>(options?: Options<LoginData, ThrowOnError>) => { export const login = <ThrowOnError extends boolean = false>(options?: Options<LoginData, ThrowOnError>) => {
return (options?.client ?? _heyApiClient).post<LoginResponses, unknown, ThrowOnError>({ return (options?.client ?? client).post<LoginResponses, unknown, ThrowOnError>({
url: "/api/v1/auth/login", url: "/api/v1/auth/login",
...options, ...options,
headers: { headers: {
@@ -133,7 +135,7 @@ export const login = <ThrowOnError extends boolean = false>(options?: Options<Lo
* Logout current user * Logout current user
*/ */
export const logout = <ThrowOnError extends boolean = false>(options?: Options<LogoutData, ThrowOnError>) => { export const logout = <ThrowOnError extends boolean = false>(options?: Options<LogoutData, ThrowOnError>) => {
return (options?.client ?? _heyApiClient).post<LogoutResponses, unknown, ThrowOnError>({ return (options?.client ?? client).post<LogoutResponses, unknown, ThrowOnError>({
url: "/api/v1/auth/logout", url: "/api/v1/auth/logout",
...options, ...options,
}); });
@@ -143,7 +145,7 @@ export const logout = <ThrowOnError extends boolean = false>(options?: Options<L
* Get current authenticated user * Get current authenticated user
*/ */
export const getMe = <ThrowOnError extends boolean = false>(options?: Options<GetMeData, ThrowOnError>) => { export const getMe = <ThrowOnError extends boolean = false>(options?: Options<GetMeData, ThrowOnError>) => {
return (options?.client ?? _heyApiClient).get<GetMeResponses, unknown, ThrowOnError>({ return (options?.client ?? client).get<GetMeResponses, unknown, ThrowOnError>({
url: "/api/v1/auth/me", url: "/api/v1/auth/me",
...options, ...options,
}); });
@@ -153,7 +155,7 @@ export const getMe = <ThrowOnError extends boolean = false>(options?: Options<Ge
* Get authentication system status * Get authentication system status
*/ */
export const getStatus = <ThrowOnError extends boolean = false>(options?: Options<GetStatusData, ThrowOnError>) => { export const getStatus = <ThrowOnError extends boolean = false>(options?: Options<GetStatusData, ThrowOnError>) => {
return (options?.client ?? _heyApiClient).get<GetStatusResponses, unknown, ThrowOnError>({ return (options?.client ?? client).get<GetStatusResponses, unknown, ThrowOnError>({
url: "/api/v1/auth/status", url: "/api/v1/auth/status",
...options, ...options,
}); });
@@ -165,7 +167,7 @@ export const getStatus = <ThrowOnError extends boolean = false>(options?: Option
export const changePassword = <ThrowOnError extends boolean = false>( export const changePassword = <ThrowOnError extends boolean = false>(
options?: Options<ChangePasswordData, ThrowOnError>, options?: Options<ChangePasswordData, ThrowOnError>,
) => { ) => {
return (options?.client ?? _heyApiClient).post<ChangePasswordResponses, unknown, ThrowOnError>({ return (options?.client ?? client).post<ChangePasswordResponses, unknown, ThrowOnError>({
url: "/api/v1/auth/change-password", url: "/api/v1/auth/change-password",
...options, ...options,
headers: { headers: {
@@ -179,7 +181,7 @@ export const changePassword = <ThrowOnError extends boolean = false>(
* List all volumes * List all volumes
*/ */
export const listVolumes = <ThrowOnError extends boolean = false>(options?: Options<ListVolumesData, ThrowOnError>) => { export const listVolumes = <ThrowOnError extends boolean = false>(options?: Options<ListVolumesData, ThrowOnError>) => {
return (options?.client ?? _heyApiClient).get<ListVolumesResponses, unknown, ThrowOnError>({ return (options?.client ?? client).get<ListVolumesResponses, unknown, ThrowOnError>({
url: "/api/v1/volumes", url: "/api/v1/volumes",
...options, ...options,
}); });
@@ -191,7 +193,7 @@ export const listVolumes = <ThrowOnError extends boolean = false>(options?: Opti
export const createVolume = <ThrowOnError extends boolean = false>( export const createVolume = <ThrowOnError extends boolean = false>(
options?: Options<CreateVolumeData, ThrowOnError>, options?: Options<CreateVolumeData, ThrowOnError>,
) => { ) => {
return (options?.client ?? _heyApiClient).post<CreateVolumeResponses, unknown, ThrowOnError>({ return (options?.client ?? client).post<CreateVolumeResponses, unknown, ThrowOnError>({
url: "/api/v1/volumes", url: "/api/v1/volumes",
...options, ...options,
headers: { headers: {
@@ -207,7 +209,7 @@ export const createVolume = <ThrowOnError extends boolean = false>(
export const testConnection = <ThrowOnError extends boolean = false>( export const testConnection = <ThrowOnError extends boolean = false>(
options?: Options<TestConnectionData, ThrowOnError>, options?: Options<TestConnectionData, ThrowOnError>,
) => { ) => {
return (options?.client ?? _heyApiClient).post<TestConnectionResponses, unknown, ThrowOnError>({ return (options?.client ?? client).post<TestConnectionResponses, unknown, ThrowOnError>({
url: "/api/v1/volumes/test-connection", url: "/api/v1/volumes/test-connection",
...options, ...options,
headers: { headers: {
@@ -223,7 +225,7 @@ export const testConnection = <ThrowOnError extends boolean = false>(
export const deleteVolume = <ThrowOnError extends boolean = false>( export const deleteVolume = <ThrowOnError extends boolean = false>(
options: Options<DeleteVolumeData, ThrowOnError>, options: Options<DeleteVolumeData, ThrowOnError>,
) => { ) => {
return (options.client ?? _heyApiClient).delete<DeleteVolumeResponses, unknown, ThrowOnError>({ return (options.client ?? client).delete<DeleteVolumeResponses, unknown, ThrowOnError>({
url: "/api/v1/volumes/{name}", url: "/api/v1/volumes/{name}",
...options, ...options,
}); });
@@ -233,7 +235,7 @@ export const deleteVolume = <ThrowOnError extends boolean = false>(
* Get a volume by name * Get a volume by name
*/ */
export const getVolume = <ThrowOnError extends boolean = false>(options: Options<GetVolumeData, ThrowOnError>) => { export const getVolume = <ThrowOnError extends boolean = false>(options: Options<GetVolumeData, ThrowOnError>) => {
return (options.client ?? _heyApiClient).get<GetVolumeResponses, GetVolumeErrors, ThrowOnError>({ return (options.client ?? client).get<GetVolumeResponses, GetVolumeErrors, ThrowOnError>({
url: "/api/v1/volumes/{name}", url: "/api/v1/volumes/{name}",
...options, ...options,
}); });
@@ -245,7 +247,7 @@ export const getVolume = <ThrowOnError extends boolean = false>(options: Options
export const updateVolume = <ThrowOnError extends boolean = false>( export const updateVolume = <ThrowOnError extends boolean = false>(
options: Options<UpdateVolumeData, ThrowOnError>, options: Options<UpdateVolumeData, ThrowOnError>,
) => { ) => {
return (options.client ?? _heyApiClient).put<UpdateVolumeResponses, UpdateVolumeErrors, ThrowOnError>({ return (options.client ?? client).put<UpdateVolumeResponses, UpdateVolumeErrors, ThrowOnError>({
url: "/api/v1/volumes/{name}", url: "/api/v1/volumes/{name}",
...options, ...options,
headers: { headers: {
@@ -261,7 +263,7 @@ export const updateVolume = <ThrowOnError extends boolean = false>(
export const getContainersUsingVolume = <ThrowOnError extends boolean = false>( export const getContainersUsingVolume = <ThrowOnError extends boolean = false>(
options: Options<GetContainersUsingVolumeData, ThrowOnError>, options: Options<GetContainersUsingVolumeData, ThrowOnError>,
) => { ) => {
return (options.client ?? _heyApiClient).get< return (options.client ?? client).get<
GetContainersUsingVolumeResponses, GetContainersUsingVolumeResponses,
GetContainersUsingVolumeErrors, GetContainersUsingVolumeErrors,
ThrowOnError ThrowOnError
@@ -275,7 +277,7 @@ export const getContainersUsingVolume = <ThrowOnError extends boolean = false>(
* Mount a volume * Mount a volume
*/ */
export const mountVolume = <ThrowOnError extends boolean = false>(options: Options<MountVolumeData, ThrowOnError>) => { export const mountVolume = <ThrowOnError extends boolean = false>(options: Options<MountVolumeData, ThrowOnError>) => {
return (options.client ?? _heyApiClient).post<MountVolumeResponses, unknown, ThrowOnError>({ return (options.client ?? client).post<MountVolumeResponses, unknown, ThrowOnError>({
url: "/api/v1/volumes/{name}/mount", url: "/api/v1/volumes/{name}/mount",
...options, ...options,
}); });
@@ -287,7 +289,7 @@ export const mountVolume = <ThrowOnError extends boolean = false>(options: Optio
export const unmountVolume = <ThrowOnError extends boolean = false>( export const unmountVolume = <ThrowOnError extends boolean = false>(
options: Options<UnmountVolumeData, ThrowOnError>, options: Options<UnmountVolumeData, ThrowOnError>,
) => { ) => {
return (options.client ?? _heyApiClient).post<UnmountVolumeResponses, unknown, ThrowOnError>({ return (options.client ?? client).post<UnmountVolumeResponses, unknown, ThrowOnError>({
url: "/api/v1/volumes/{name}/unmount", url: "/api/v1/volumes/{name}/unmount",
...options, ...options,
}); });
@@ -299,7 +301,7 @@ export const unmountVolume = <ThrowOnError extends boolean = false>(
export const healthCheckVolume = <ThrowOnError extends boolean = false>( export const healthCheckVolume = <ThrowOnError extends boolean = false>(
options: Options<HealthCheckVolumeData, ThrowOnError>, options: Options<HealthCheckVolumeData, ThrowOnError>,
) => { ) => {
return (options.client ?? _heyApiClient).post<HealthCheckVolumeResponses, HealthCheckVolumeErrors, ThrowOnError>({ return (options.client ?? client).post<HealthCheckVolumeResponses, HealthCheckVolumeErrors, ThrowOnError>({
url: "/api/v1/volumes/{name}/health-check", url: "/api/v1/volumes/{name}/health-check",
...options, ...options,
}); });
@@ -309,7 +311,7 @@ export const healthCheckVolume = <ThrowOnError extends boolean = false>(
* List files in a volume directory * List files in a volume directory
*/ */
export const listFiles = <ThrowOnError extends boolean = false>(options: Options<ListFilesData, ThrowOnError>) => { export const listFiles = <ThrowOnError extends boolean = false>(options: Options<ListFilesData, ThrowOnError>) => {
return (options.client ?? _heyApiClient).get<ListFilesResponses, unknown, ThrowOnError>({ return (options.client ?? client).get<ListFilesResponses, unknown, ThrowOnError>({
url: "/api/v1/volumes/{name}/files", url: "/api/v1/volumes/{name}/files",
...options, ...options,
}); });
@@ -321,7 +323,7 @@ export const listFiles = <ThrowOnError extends boolean = false>(options: Options
export const browseFilesystem = <ThrowOnError extends boolean = false>( export const browseFilesystem = <ThrowOnError extends boolean = false>(
options?: Options<BrowseFilesystemData, ThrowOnError>, options?: Options<BrowseFilesystemData, ThrowOnError>,
) => { ) => {
return (options?.client ?? _heyApiClient).get<BrowseFilesystemResponses, unknown, ThrowOnError>({ return (options?.client ?? client).get<BrowseFilesystemResponses, unknown, ThrowOnError>({
url: "/api/v1/volumes/filesystem/browse", url: "/api/v1/volumes/filesystem/browse",
...options, ...options,
}); });
@@ -333,7 +335,7 @@ export const browseFilesystem = <ThrowOnError extends boolean = false>(
export const listRepositories = <ThrowOnError extends boolean = false>( export const listRepositories = <ThrowOnError extends boolean = false>(
options?: Options<ListRepositoriesData, ThrowOnError>, options?: Options<ListRepositoriesData, ThrowOnError>,
) => { ) => {
return (options?.client ?? _heyApiClient).get<ListRepositoriesResponses, unknown, ThrowOnError>({ return (options?.client ?? client).get<ListRepositoriesResponses, unknown, ThrowOnError>({
url: "/api/v1/repositories", url: "/api/v1/repositories",
...options, ...options,
}); });
@@ -345,7 +347,7 @@ export const listRepositories = <ThrowOnError extends boolean = false>(
export const createRepository = <ThrowOnError extends boolean = false>( export const createRepository = <ThrowOnError extends boolean = false>(
options?: Options<CreateRepositoryData, ThrowOnError>, options?: Options<CreateRepositoryData, ThrowOnError>,
) => { ) => {
return (options?.client ?? _heyApiClient).post<CreateRepositoryResponses, unknown, ThrowOnError>({ return (options?.client ?? client).post<CreateRepositoryResponses, unknown, ThrowOnError>({
url: "/api/v1/repositories", url: "/api/v1/repositories",
...options, ...options,
headers: { headers: {
@@ -355,13 +357,25 @@ export const createRepository = <ThrowOnError extends boolean = false>(
}); });
}; };
/**
* List all configured rclone remotes on the host system
*/
export const listRcloneRemotes = <ThrowOnError extends boolean = false>(
options?: Options<ListRcloneRemotesData, ThrowOnError>,
) => {
return (options?.client ?? client).get<ListRcloneRemotesResponses, unknown, ThrowOnError>({
url: "/api/v1/repositories/rclone-remotes",
...options,
});
};
/** /**
* Delete a repository * Delete a repository
*/ */
export const deleteRepository = <ThrowOnError extends boolean = false>( export const deleteRepository = <ThrowOnError extends boolean = false>(
options: Options<DeleteRepositoryData, ThrowOnError>, options: Options<DeleteRepositoryData, ThrowOnError>,
) => { ) => {
return (options.client ?? _heyApiClient).delete<DeleteRepositoryResponses, unknown, ThrowOnError>({ return (options.client ?? client).delete<DeleteRepositoryResponses, unknown, ThrowOnError>({
url: "/api/v1/repositories/{name}", url: "/api/v1/repositories/{name}",
...options, ...options,
}); });
@@ -373,7 +387,7 @@ export const deleteRepository = <ThrowOnError extends boolean = false>(
export const getRepository = <ThrowOnError extends boolean = false>( export const getRepository = <ThrowOnError extends boolean = false>(
options: Options<GetRepositoryData, ThrowOnError>, options: Options<GetRepositoryData, ThrowOnError>,
) => { ) => {
return (options.client ?? _heyApiClient).get<GetRepositoryResponses, unknown, ThrowOnError>({ return (options.client ?? client).get<GetRepositoryResponses, unknown, ThrowOnError>({
url: "/api/v1/repositories/{name}", url: "/api/v1/repositories/{name}",
...options, ...options,
}); });
@@ -385,7 +399,7 @@ export const getRepository = <ThrowOnError extends boolean = false>(
export const listSnapshots = <ThrowOnError extends boolean = false>( export const listSnapshots = <ThrowOnError extends boolean = false>(
options: Options<ListSnapshotsData, ThrowOnError>, options: Options<ListSnapshotsData, ThrowOnError>,
) => { ) => {
return (options.client ?? _heyApiClient).get<ListSnapshotsResponses, unknown, ThrowOnError>({ return (options.client ?? client).get<ListSnapshotsResponses, unknown, ThrowOnError>({
url: "/api/v1/repositories/{name}/snapshots", url: "/api/v1/repositories/{name}/snapshots",
...options, ...options,
}); });
@@ -397,7 +411,7 @@ export const listSnapshots = <ThrowOnError extends boolean = false>(
export const getSnapshotDetails = <ThrowOnError extends boolean = false>( export const getSnapshotDetails = <ThrowOnError extends boolean = false>(
options: Options<GetSnapshotDetailsData, ThrowOnError>, options: Options<GetSnapshotDetailsData, ThrowOnError>,
) => { ) => {
return (options.client ?? _heyApiClient).get<GetSnapshotDetailsResponses, unknown, ThrowOnError>({ return (options.client ?? client).get<GetSnapshotDetailsResponses, unknown, ThrowOnError>({
url: "/api/v1/repositories/{name}/snapshots/{snapshotId}", url: "/api/v1/repositories/{name}/snapshots/{snapshotId}",
...options, ...options,
}); });
@@ -409,7 +423,7 @@ export const getSnapshotDetails = <ThrowOnError extends boolean = false>(
export const listSnapshotFiles = <ThrowOnError extends boolean = false>( export const listSnapshotFiles = <ThrowOnError extends boolean = false>(
options: Options<ListSnapshotFilesData, ThrowOnError>, options: Options<ListSnapshotFilesData, ThrowOnError>,
) => { ) => {
return (options.client ?? _heyApiClient).get<ListSnapshotFilesResponses, unknown, ThrowOnError>({ return (options.client ?? client).get<ListSnapshotFilesResponses, unknown, ThrowOnError>({
url: "/api/v1/repositories/{name}/snapshots/{snapshotId}/files", url: "/api/v1/repositories/{name}/snapshots/{snapshotId}/files",
...options, ...options,
}); });
@@ -421,7 +435,7 @@ export const listSnapshotFiles = <ThrowOnError extends boolean = false>(
export const restoreSnapshot = <ThrowOnError extends boolean = false>( export const restoreSnapshot = <ThrowOnError extends boolean = false>(
options: Options<RestoreSnapshotData, ThrowOnError>, options: Options<RestoreSnapshotData, ThrowOnError>,
) => { ) => {
return (options.client ?? _heyApiClient).post<RestoreSnapshotResponses, unknown, ThrowOnError>({ return (options.client ?? client).post<RestoreSnapshotResponses, unknown, ThrowOnError>({
url: "/api/v1/repositories/{name}/restore", url: "/api/v1/repositories/{name}/restore",
...options, ...options,
headers: { headers: {
@@ -437,7 +451,7 @@ export const restoreSnapshot = <ThrowOnError extends boolean = false>(
export const doctorRepository = <ThrowOnError extends boolean = false>( export const doctorRepository = <ThrowOnError extends boolean = false>(
options: Options<DoctorRepositoryData, ThrowOnError>, options: Options<DoctorRepositoryData, ThrowOnError>,
) => { ) => {
return (options.client ?? _heyApiClient).post<DoctorRepositoryResponses, unknown, ThrowOnError>({ return (options.client ?? client).post<DoctorRepositoryResponses, unknown, ThrowOnError>({
url: "/api/v1/repositories/{name}/doctor", url: "/api/v1/repositories/{name}/doctor",
...options, ...options,
}); });
@@ -449,7 +463,7 @@ export const doctorRepository = <ThrowOnError extends boolean = false>(
export const listBackupSchedules = <ThrowOnError extends boolean = false>( export const listBackupSchedules = <ThrowOnError extends boolean = false>(
options?: Options<ListBackupSchedulesData, ThrowOnError>, options?: Options<ListBackupSchedulesData, ThrowOnError>,
) => { ) => {
return (options?.client ?? _heyApiClient).get<ListBackupSchedulesResponses, unknown, ThrowOnError>({ return (options?.client ?? client).get<ListBackupSchedulesResponses, unknown, ThrowOnError>({
url: "/api/v1/backups", url: "/api/v1/backups",
...options, ...options,
}); });
@@ -461,7 +475,7 @@ export const listBackupSchedules = <ThrowOnError extends boolean = false>(
export const createBackupSchedule = <ThrowOnError extends boolean = false>( export const createBackupSchedule = <ThrowOnError extends boolean = false>(
options?: Options<CreateBackupScheduleData, ThrowOnError>, options?: Options<CreateBackupScheduleData, ThrowOnError>,
) => { ) => {
return (options?.client ?? _heyApiClient).post<CreateBackupScheduleResponses, unknown, ThrowOnError>({ return (options?.client ?? client).post<CreateBackupScheduleResponses, unknown, ThrowOnError>({
url: "/api/v1/backups", url: "/api/v1/backups",
...options, ...options,
headers: { headers: {
@@ -477,7 +491,7 @@ export const createBackupSchedule = <ThrowOnError extends boolean = false>(
export const deleteBackupSchedule = <ThrowOnError extends boolean = false>( export const deleteBackupSchedule = <ThrowOnError extends boolean = false>(
options: Options<DeleteBackupScheduleData, ThrowOnError>, options: Options<DeleteBackupScheduleData, ThrowOnError>,
) => { ) => {
return (options.client ?? _heyApiClient).delete<DeleteBackupScheduleResponses, unknown, ThrowOnError>({ return (options.client ?? client).delete<DeleteBackupScheduleResponses, unknown, ThrowOnError>({
url: "/api/v1/backups/{scheduleId}", url: "/api/v1/backups/{scheduleId}",
...options, ...options,
}); });
@@ -489,7 +503,7 @@ export const deleteBackupSchedule = <ThrowOnError extends boolean = false>(
export const getBackupSchedule = <ThrowOnError extends boolean = false>( export const getBackupSchedule = <ThrowOnError extends boolean = false>(
options: Options<GetBackupScheduleData, ThrowOnError>, options: Options<GetBackupScheduleData, ThrowOnError>,
) => { ) => {
return (options.client ?? _heyApiClient).get<GetBackupScheduleResponses, unknown, ThrowOnError>({ return (options.client ?? client).get<GetBackupScheduleResponses, unknown, ThrowOnError>({
url: "/api/v1/backups/{scheduleId}", url: "/api/v1/backups/{scheduleId}",
...options, ...options,
}); });
@@ -501,7 +515,7 @@ export const getBackupSchedule = <ThrowOnError extends boolean = false>(
export const updateBackupSchedule = <ThrowOnError extends boolean = false>( export const updateBackupSchedule = <ThrowOnError extends boolean = false>(
options: Options<UpdateBackupScheduleData, ThrowOnError>, options: Options<UpdateBackupScheduleData, ThrowOnError>,
) => { ) => {
return (options.client ?? _heyApiClient).patch<UpdateBackupScheduleResponses, unknown, ThrowOnError>({ return (options.client ?? client).patch<UpdateBackupScheduleResponses, unknown, ThrowOnError>({
url: "/api/v1/backups/{scheduleId}", url: "/api/v1/backups/{scheduleId}",
...options, ...options,
headers: { headers: {
@@ -517,7 +531,7 @@ export const updateBackupSchedule = <ThrowOnError extends boolean = false>(
export const getBackupScheduleForVolume = <ThrowOnError extends boolean = false>( export const getBackupScheduleForVolume = <ThrowOnError extends boolean = false>(
options: Options<GetBackupScheduleForVolumeData, ThrowOnError>, options: Options<GetBackupScheduleForVolumeData, ThrowOnError>,
) => { ) => {
return (options.client ?? _heyApiClient).get<GetBackupScheduleForVolumeResponses, unknown, ThrowOnError>({ return (options.client ?? client).get<GetBackupScheduleForVolumeResponses, unknown, ThrowOnError>({
url: "/api/v1/backups/volume/{volumeId}", url: "/api/v1/backups/volume/{volumeId}",
...options, ...options,
}); });
@@ -529,7 +543,7 @@ export const getBackupScheduleForVolume = <ThrowOnError extends boolean = false>
export const runBackupNow = <ThrowOnError extends boolean = false>( export const runBackupNow = <ThrowOnError extends boolean = false>(
options: Options<RunBackupNowData, ThrowOnError>, options: Options<RunBackupNowData, ThrowOnError>,
) => { ) => {
return (options.client ?? _heyApiClient).post<RunBackupNowResponses, unknown, ThrowOnError>({ return (options.client ?? client).post<RunBackupNowResponses, unknown, ThrowOnError>({
url: "/api/v1/backups/{scheduleId}/run", url: "/api/v1/backups/{scheduleId}/run",
...options, ...options,
}); });
@@ -539,7 +553,7 @@ export const runBackupNow = <ThrowOnError extends boolean = false>(
* Stop a backup that is currently in progress * Stop a backup that is currently in progress
*/ */
export const stopBackup = <ThrowOnError extends boolean = false>(options: Options<StopBackupData, ThrowOnError>) => { export const stopBackup = <ThrowOnError extends boolean = false>(options: Options<StopBackupData, ThrowOnError>) => {
return (options.client ?? _heyApiClient).post<StopBackupResponses, StopBackupErrors, ThrowOnError>({ return (options.client ?? client).post<StopBackupResponses, StopBackupErrors, ThrowOnError>({
url: "/api/v1/backups/{scheduleId}/stop", url: "/api/v1/backups/{scheduleId}/stop",
...options, ...options,
}); });
@@ -551,7 +565,7 @@ export const stopBackup = <ThrowOnError extends boolean = false>(options: Option
export const getSystemInfo = <ThrowOnError extends boolean = false>( export const getSystemInfo = <ThrowOnError extends boolean = false>(
options?: Options<GetSystemInfoData, ThrowOnError>, options?: Options<GetSystemInfoData, ThrowOnError>,
) => { ) => {
return (options?.client ?? _heyApiClient).get<GetSystemInfoResponses, unknown, ThrowOnError>({ return (options?.client ?? client).get<GetSystemInfoResponses, unknown, ThrowOnError>({
url: "/api/v1/system/info", url: "/api/v1/system/info",
...options, ...options,
}); });
@@ -563,7 +577,7 @@ export const getSystemInfo = <ThrowOnError extends boolean = false>(
export const downloadResticPassword = <ThrowOnError extends boolean = false>( export const downloadResticPassword = <ThrowOnError extends boolean = false>(
options?: Options<DownloadResticPasswordData, ThrowOnError>, options?: Options<DownloadResticPasswordData, ThrowOnError>,
) => { ) => {
return (options?.client ?? _heyApiClient).post<DownloadResticPasswordResponses, unknown, ThrowOnError>({ return (options?.client ?? client).post<DownloadResticPasswordResponses, unknown, ThrowOnError>({
url: "/api/v1/system/restic-password", url: "/api/v1/system/restic-password",
...options, ...options,
headers: { headers: {

View File

@@ -1,5 +1,9 @@
// This file is auto-generated by @hey-api/openapi-ts // This file is auto-generated by @hey-api/openapi-ts
export type ClientOptions = {
baseUrl: "http://192.168.2.42:4096" | (string & {});
};
export type RegisterData = { export type RegisterData = {
body?: { body?: {
password: string; password: string;
@@ -738,9 +742,27 @@ export type ListRepositoriesResponses = {
endpoint: string; endpoint: string;
secretAccessKey: string; secretAccessKey: string;
} }
| {
accountKey: string;
accountName: string;
backend: "azure";
container: string;
endpointSuffix?: string;
}
| {
backend: "gcs";
bucket: string;
credentialsJson: string;
projectId: string;
}
| { | {
backend: "local"; backend: "local";
name: string; name: string;
}
| {
backend: "rclone";
path: string;
remote: string;
}; };
createdAt: number; createdAt: number;
id: string; id: string;
@@ -748,7 +770,7 @@ export type ListRepositoriesResponses = {
lastError: string | null; lastError: string | null;
name: string; name: string;
status: "error" | "healthy" | "unknown" | null; status: "error" | "healthy" | "unknown" | null;
type: "local" | "s3"; type: "azure" | "gcs" | "local" | "rclone" | "s3";
updatedAt: number; updatedAt: number;
}>; }>;
}; };
@@ -765,9 +787,27 @@ export type CreateRepositoryData = {
endpoint: string; endpoint: string;
secretAccessKey: string; secretAccessKey: string;
} }
| {
accountKey: string;
accountName: string;
backend: "azure";
container: string;
endpointSuffix?: string;
}
| {
backend: "gcs";
bucket: string;
credentialsJson: string;
projectId: string;
}
| { | {
backend: "local"; backend: "local";
name: string; name: string;
}
| {
backend: "rclone";
path: string;
remote: string;
}; };
name: string; name: string;
compressionMode?: "auto" | "better" | "fastest" | "max" | "off"; compressionMode?: "auto" | "better" | "fastest" | "max" | "off";
@@ -792,6 +832,25 @@ export type CreateRepositoryResponses = {
export type CreateRepositoryResponse = CreateRepositoryResponses[keyof CreateRepositoryResponses]; export type CreateRepositoryResponse = CreateRepositoryResponses[keyof CreateRepositoryResponses];
export type ListRcloneRemotesData = {
body?: never;
path?: never;
query?: never;
url: "/api/v1/repositories/rclone-remotes";
};
export type ListRcloneRemotesResponses = {
/**
* List of rclone remotes
*/
200: Array<{
name: string;
type: string;
}>;
};
export type ListRcloneRemotesResponse = ListRcloneRemotesResponses[keyof ListRcloneRemotesResponses];
export type DeleteRepositoryData = { export type DeleteRepositoryData = {
body?: never; body?: never;
path: { path: {
@@ -835,9 +894,27 @@ export type GetRepositoryResponses = {
endpoint: string; endpoint: string;
secretAccessKey: string; secretAccessKey: string;
} }
| {
accountKey: string;
accountName: string;
backend: "azure";
container: string;
endpointSuffix?: string;
}
| {
backend: "gcs";
bucket: string;
credentialsJson: string;
projectId: string;
}
| { | {
backend: "local"; backend: "local";
name: string; name: string;
}
| {
backend: "rclone";
path: string;
remote: string;
}; };
createdAt: number; createdAt: number;
id: string; id: string;
@@ -845,7 +922,7 @@ export type GetRepositoryResponses = {
lastError: string | null; lastError: string | null;
name: string; name: string;
status: "error" | "healthy" | "unknown" | null; status: "error" | "healthy" | "unknown" | null;
type: "local" | "s3"; type: "azure" | "gcs" | "local" | "rclone" | "s3";
updatedAt: number; updatedAt: number;
}; };
}; };
@@ -1030,9 +1107,27 @@ export type ListBackupSchedulesResponses = {
endpoint: string; endpoint: string;
secretAccessKey: string; secretAccessKey: string;
} }
| {
accountKey: string;
accountName: string;
backend: "azure";
container: string;
endpointSuffix?: string;
}
| {
backend: "gcs";
bucket: string;
credentialsJson: string;
projectId: string;
}
| { | {
backend: "local"; backend: "local";
name: string; name: string;
}
| {
backend: "rclone";
path: string;
remote: string;
}; };
createdAt: number; createdAt: number;
id: string; id: string;
@@ -1040,7 +1135,7 @@ export type ListBackupSchedulesResponses = {
lastError: string | null; lastError: string | null;
name: string; name: string;
status: "error" | "healthy" | "unknown" | null; status: "error" | "healthy" | "unknown" | null;
type: "local" | "s3"; type: "azure" | "gcs" | "local" | "rclone" | "s3";
updatedAt: number; updatedAt: number;
}; };
repositoryId: string; repositoryId: string;
@@ -1216,9 +1311,27 @@ export type GetBackupScheduleResponses = {
endpoint: string; endpoint: string;
secretAccessKey: string; secretAccessKey: string;
} }
| {
accountKey: string;
accountName: string;
backend: "azure";
container: string;
endpointSuffix?: string;
}
| {
backend: "gcs";
bucket: string;
credentialsJson: string;
projectId: string;
}
| { | {
backend: "local"; backend: "local";
name: string; name: string;
}
| {
backend: "rclone";
path: string;
remote: string;
}; };
createdAt: number; createdAt: number;
id: string; id: string;
@@ -1226,7 +1339,7 @@ export type GetBackupScheduleResponses = {
lastError: string | null; lastError: string | null;
name: string; name: string;
status: "error" | "healthy" | "unknown" | null; status: "error" | "healthy" | "unknown" | null;
type: "local" | "s3"; type: "azure" | "gcs" | "local" | "rclone" | "s3";
updatedAt: number; updatedAt: number;
}; };
repositoryId: string; repositoryId: string;
@@ -1383,9 +1496,27 @@ export type GetBackupScheduleForVolumeResponses = {
endpoint: string; endpoint: string;
secretAccessKey: string; secretAccessKey: string;
} }
| {
accountKey: string;
accountName: string;
backend: "azure";
container: string;
endpointSuffix?: string;
}
| {
backend: "gcs";
bucket: string;
credentialsJson: string;
projectId: string;
}
| { | {
backend: "local"; backend: "local";
name: string; name: string;
}
| {
backend: "rclone";
path: string;
remote: string;
}; };
createdAt: number; createdAt: number;
id: string; id: string;
@@ -1393,7 +1524,7 @@ export type GetBackupScheduleForVolumeResponses = {
lastError: string | null; lastError: string | null;
name: string; name: string;
status: "error" | "healthy" | "unknown" | null; status: "error" | "healthy" | "unknown" | null;
type: "local" | "s3"; type: "azure" | "gcs" | "local" | "rclone" | "s3";
updatedAt: number; updatedAt: number;
}; };
repositoryId: string; repositoryId: string;
@@ -1521,6 +1652,7 @@ export type GetSystemInfoResponses = {
200: { 200: {
capabilities: { capabilities: {
docker: boolean; docker: boolean;
rclone: boolean;
}; };
}; };
}; };
@@ -1544,7 +1676,3 @@ export type DownloadResticPasswordResponses = {
}; };
export type DownloadResticPasswordResponse = DownloadResticPasswordResponses[keyof DownloadResticPasswordResponses]; export type DownloadResticPasswordResponse = DownloadResticPasswordResponses[keyof DownloadResticPasswordResponses];
export type ClientOptions = {
baseUrl: "http://192.168.2.42:4096" | (string & {});
};

View File

@@ -6,8 +6,8 @@ import {
BreadcrumbList, BreadcrumbList,
BreadcrumbPage, BreadcrumbPage,
BreadcrumbSeparator, BreadcrumbSeparator,
} from "~/components/ui/breadcrumb"; } from "~/client/components/ui/breadcrumb";
import { useBreadcrumbs } from "~/lib/breadcrumbs"; import { useBreadcrumbs } from "~/client/lib/breadcrumbs";
export function AppBreadcrumb() { export function AppBreadcrumb() {
const breadcrumbs = useBreadcrumbs(); const breadcrumbs = useBreadcrumbs();

View File

@@ -10,9 +10,9 @@ import {
SidebarMenuButton, SidebarMenuButton,
SidebarMenuItem, SidebarMenuItem,
useSidebar, useSidebar,
} from "~/components/ui/sidebar"; } from "~/client/components/ui/sidebar";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "~/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "~/client/components/ui/tooltip";
import { cn } from "~/lib/utils"; import { cn } from "~/client/lib/utils";
const items = [ const items = [
{ {

View File

@@ -2,12 +2,12 @@ import { useMutation } from "@tanstack/react-query";
import { Plus } from "lucide-react"; import { Plus } from "lucide-react";
import { useId } from "react"; import { useId } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { createRepositoryMutation } from "~/api-client/@tanstack/react-query.gen"; import { parseError } from "~/client/lib/errors";
import { parseError } from "~/lib/errors";
import { CreateRepositoryForm } from "./create-repository-form"; import { CreateRepositoryForm } from "./create-repository-form";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "./ui/dialog"; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "./ui/dialog";
import { ScrollArea } from "./ui/scroll-area"; import { ScrollArea } from "./ui/scroll-area";
import { createRepositoryMutation } from "../api-client/@tanstack/react-query.gen";
type Props = { type Props = {
open: boolean; open: boolean;

View File

@@ -0,0 +1,410 @@
import { arktypeResolver } from "@hookform/resolvers/arktype";
import { type } from "arktype";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { cn, slugify } from "~/client/lib/utils";
import { deepClean } from "~/utils/object";
import { Button } from "./ui/button";
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "./ui/form";
import { Input } from "./ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
import { useQuery } from "@tanstack/react-query";
import { Alert, AlertDescription } from "./ui/alert";
import { ExternalLink } from "lucide-react";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
import { useSystemInfo } from "~/client/hooks/use-system-info";
import { COMPRESSION_MODES, repositoryConfigSchema } from "~/schemas/restic";
import { listRcloneRemotesOptions } from "../api-client/@tanstack/react-query.gen";
export const formSchema = type({
name: "2<=string<=32",
compressionMode: type.valueOf(COMPRESSION_MODES).optional(),
}).and(repositoryConfigSchema);
const cleanSchema = type.pipe((d) => formSchema(deepClean(d)));
export type RepositoryFormValues = typeof formSchema.inferIn;
type Props = {
onSubmit: (values: RepositoryFormValues) => void;
mode?: "create" | "update";
initialValues?: Partial<RepositoryFormValues>;
formId?: string;
loading?: boolean;
className?: string;
};
const defaultValuesForType = {
local: { backend: "local" as const, compressionMode: "auto" as const },
s3: { backend: "s3" as const, compressionMode: "auto" as const },
gcs: { backend: "gcs" as const, compressionMode: "auto" as const },
azure: { backend: "azure" as const, compressionMode: "auto" as const },
rclone: { backend: "rclone" as const, compressionMode: "auto" as const },
};
export const CreateRepositoryForm = ({
onSubmit,
mode = "create",
initialValues,
formId,
loading,
className,
}: Props) => {
const form = useForm<RepositoryFormValues>({
resolver: arktypeResolver(cleanSchema as unknown as typeof formSchema),
defaultValues: initialValues,
resetOptions: {
keepDefaultValues: true,
keepDirtyValues: false,
},
});
const { watch } = form;
const watchedBackend = watch("backend");
const { data: rcloneRemotes, isLoading: isLoadingRemotes } = useQuery({
...listRcloneRemotesOptions(),
});
useEffect(() => {
form.reset({
name: form.getValues().name,
...defaultValuesForType[watchedBackend as keyof typeof defaultValuesForType],
});
}, [watchedBackend, form]);
const { capabilities } = useSystemInfo();
return (
<Form {...form}>
<form id={formId} onSubmit={form.handleSubmit(onSubmit)} className={cn("space-y-4", className)}>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input
{...field}
placeholder="Repository name"
onChange={(e) => field.onChange(slugify(e.target.value))}
max={32}
min={2}
disabled={mode === "update"}
className={mode === "update" ? "bg-gray-50" : ""}
/>
</FormControl>
<FormDescription>Unique identifier for the repository.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="backend"
render={({ field }) => (
<FormItem>
<FormLabel>Backend</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a backend" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="local">Local</SelectItem>
<SelectItem value="s3">S3</SelectItem>
<SelectItem value="gcs">Google Cloud Storage</SelectItem>
<SelectItem value="azure">Azure Blob Storage</SelectItem>
<Tooltip>
<TooltipTrigger>
<SelectItem disabled={!capabilities.rclone} value="rclone">
rclone (40+ cloud providers)
</SelectItem>
</TooltipTrigger>
<TooltipContent className={cn({ hidden: capabilities.rclone })}>
<p>Setup rclone to use this backend</p>
</TooltipContent>
</Tooltip>
</SelectContent>
</Select>
<FormDescription>Choose the storage backend for this repository.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="compressionMode"
render={({ field }) => (
<FormItem>
<FormLabel>Compression Mode</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select compression mode" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="off">Off</SelectItem>
<SelectItem value="auto">Auto</SelectItem>
<SelectItem value="fastest">Fastest</SelectItem>
<SelectItem value="better">Better</SelectItem>
<SelectItem value="max">Max</SelectItem>
</SelectContent>
</Select>
<FormDescription>Compression mode for backups stored in this repository.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{watchedBackend === "s3" && (
<>
<FormField
control={form.control}
name="endpoint"
render={({ field }) => (
<FormItem>
<FormLabel>Endpoint</FormLabel>
<FormControl>
<Input placeholder="s3.amazonaws.com" {...field} />
</FormControl>
<FormDescription>S3-compatible endpoint URL.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="bucket"
render={({ field }) => (
<FormItem>
<FormLabel>Bucket</FormLabel>
<FormControl>
<Input placeholder="my-backup-bucket" {...field} />
</FormControl>
<FormDescription>S3 bucket name for storing backups.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="accessKeyId"
render={({ field }) => (
<FormItem>
<FormLabel>Access Key ID</FormLabel>
<FormControl>
<Input placeholder="AKIAIOSFODNN7EXAMPLE" {...field} />
</FormControl>
<FormDescription>S3 access key ID for authentication.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="secretAccessKey"
render={({ field }) => (
<FormItem>
<FormLabel>Secret Access Key</FormLabel>
<FormControl>
<Input type="password" placeholder="••••••••" {...field} />
</FormControl>
<FormDescription>S3 secret access key for authentication.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{watchedBackend === "gcs" && (
<>
<FormField
control={form.control}
name="bucket"
render={({ field }) => (
<FormItem>
<FormLabel>Bucket</FormLabel>
<FormControl>
<Input placeholder="my-backup-bucket" {...field} />
</FormControl>
<FormDescription>GCS bucket name for storing backups.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="projectId"
render={({ field }) => (
<FormItem>
<FormLabel>Project ID</FormLabel>
<FormControl>
<Input placeholder="my-gcp-project-123" {...field} />
</FormControl>
<FormDescription>Google Cloud project ID.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="credentialsJson"
render={({ field }) => (
<FormItem>
<FormLabel>Service Account JSON</FormLabel>
<FormControl>
<Input type="password" placeholder="Paste service account JSON key..." {...field} />
</FormControl>
<FormDescription>Service account JSON credentials for authentication.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{watchedBackend === "azure" && (
<>
<FormField
control={form.control}
name="container"
render={({ field }) => (
<FormItem>
<FormLabel>Container</FormLabel>
<FormControl>
<Input placeholder="my-backup-container" {...field} />
</FormControl>
<FormDescription>Azure Blob Storage container name for storing backups.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="accountName"
render={({ field }) => (
<FormItem>
<FormLabel>Account Name</FormLabel>
<FormControl>
<Input placeholder="mystorageaccount" {...field} />
</FormControl>
<FormDescription>Azure Storage account name.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="accountKey"
render={({ field }) => (
<FormItem>
<FormLabel>Account Key</FormLabel>
<FormControl>
<Input type="password" placeholder="••••••••" {...field} />
</FormControl>
<FormDescription>Azure Storage account key for authentication.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="endpointSuffix"
render={({ field }) => (
<FormItem>
<FormLabel>Endpoint Suffix (Optional)</FormLabel>
<FormControl>
<Input placeholder="core.windows.net" {...field} />
</FormControl>
<FormDescription>Custom Azure endpoint suffix (defaults to core.windows.net).</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{watchedBackend === "rclone" &&
(!rcloneRemotes || rcloneRemotes.length === 0 ? (
<Alert>
<AlertDescription className="space-y-2">
<p className="font-medium">No rclone remotes configured</p>
<p className="text-sm text-muted-foreground">
To use rclone, you need to configure remotes on your host system
</p>
<a
href="https://rclone.org/docs/"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-strong-accent inline-flex items-center gap-1"
>
View rclone documentation
<ExternalLink className="w-3 h-3" />
</a>
</AlertDescription>
</Alert>
) : (
<>
<FormField
control={form.control}
name="remote"
render={({ field }) => (
<FormItem>
<FormLabel>Remote</FormLabel>
<Select onValueChange={(v) => field.onChange(v)} defaultValue={field.value} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select an rclone remote" />
</SelectTrigger>
</FormControl>
<SelectContent>
{isLoadingRemotes ? (
<SelectItem value="loading" disabled>
Loading remotes...
</SelectItem>
) : (
rcloneRemotes.map((remote: { name: string; type: string }) => (
<SelectItem key={remote.name} value={remote.name}>
{remote.name} ({remote.type})
</SelectItem>
))
)}
</SelectContent>
</Select>
<FormDescription>Select the rclone remote configured on your host system.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="path"
render={({ field }) => (
<FormItem>
<FormLabel>Path</FormLabel>
<FormControl>
<Input placeholder="backups/ironmount" {...field} />
</FormControl>
<FormDescription>Path within the remote where backups will be stored.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
))}
{mode === "update" && (
<Button type="submit" className="w-full" loading={loading}>
Save Changes
</Button>
)}
</form>
</Form>
);
};

View File

@@ -2,12 +2,12 @@ import { useMutation } from "@tanstack/react-query";
import { Plus } from "lucide-react"; import { Plus } from "lucide-react";
import { useId } from "react"; import { useId } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { createVolumeMutation } from "~/api-client/@tanstack/react-query.gen"; import { parseError } from "~/client/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 { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "./ui/dialog"; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "./ui/dialog";
import { ScrollArea } from "./ui/scroll-area"; import { ScrollArea } from "./ui/scroll-area";
import { createVolumeMutation } from "../api-client/@tanstack/react-query.gen";
type Props = { type Props = {
open: boolean; open: boolean;

View File

@@ -1,18 +1,18 @@
import { arktypeResolver } from "@hookform/resolvers/arktype"; import { arktypeResolver } from "@hookform/resolvers/arktype";
import { volumeConfigSchema } from "@ironmount/schemas";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { type } from "arktype"; import { type } from "arktype";
import { CheckCircle, Loader2, XCircle } from "lucide-react"; 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 { cn, slugify } from "~/client/lib/utils";
import { cn, slugify } from "~/lib/utils";
import { deepClean } from "~/utils/object"; import { deepClean } from "~/utils/object";
import { DirectoryBrowser } from "./directory-browser"; import { DirectoryBrowser } from "./directory-browser";
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";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
import { volumeConfigSchema } from "~/schemas/volumes";
import { testConnectionMutation } from "../api-client/@tanstack/react-query.gen";
export const formSchema = type({ export const formSchema = type({
name: "2<=string<=32", name: "2<=string<=32",
@@ -50,13 +50,15 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
const { watch, getValues } = form; const { watch, getValues } = form;
const watchedBackend = watch("backend"); const watchedBackend = watch("backend");
const watchedName = watch("name");
useEffect(() => { useEffect(() => {
if (mode === "create") { if (mode === "create") {
form.reset({ name: watchedName, ...defaultValuesForType[watchedBackend as keyof typeof defaultValuesForType] }); form.reset({
name: form.getValues().name,
...defaultValuesForType[watchedBackend as keyof typeof defaultValuesForType],
});
} }
}, [watchedBackend, watchedName, form.reset, mode]); }, [watchedBackend, form, mode]);
const [testMessage, setTestMessage] = useState<{ success: boolean; message: string } | null>(null); const [testMessage, setTestMessage] = useState<{ success: boolean; message: string } | null>(null);
@@ -141,19 +143,17 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
control={form.control} control={form.control}
name="path" name="path"
render={({ field }) => { render={({ field }) => {
const [showBrowser, setShowBrowser] = useState(!field.value || field.value === "/");
return ( return (
<FormItem> <FormItem>
<FormLabel>Directory Path</FormLabel> <FormLabel>Directory Path</FormLabel>
<FormControl> <FormControl>
{!showBrowser && field.value ? ( {field.value ? (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="flex-1 border rounded-md p-3 bg-muted/50"> <div className="flex-1 border rounded-md p-3 bg-muted/50">
<div className="text-xs font-medium text-muted-foreground mb-1">Selected path:</div> <div className="text-xs font-medium text-muted-foreground mb-1">Selected path:</div>
<div className="text-sm font-mono break-all">{field.value}</div> <div className="text-sm font-mono break-all">{field.value}</div>
</div> </div>
<Button type="button" variant="outline" size="sm" onClick={() => setShowBrowser(true)}> <Button type="button" variant="outline" size="sm" onClick={() => field.onChange("")}>
Change Change
</Button> </Button>
</div> </div>

View File

@@ -1,8 +1,8 @@
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { browseFilesystemOptions } from "~/api-client/@tanstack/react-query.gen";
import { FileTree, type FileEntry } from "./file-tree"; import { FileTree, type FileEntry } from "./file-tree";
import { ScrollArea } from "./ui/scroll-area"; import { ScrollArea } from "./ui/scroll-area";
import { browseFilesystemOptions } from "../api-client/@tanstack/react-query.gen";
type Props = { type Props = {
onSelectPath: (path: string) => void; onSelectPath: (path: string) => void;
@@ -46,7 +46,7 @@ export const DirectoryBrowser = ({ onSelectPath, selectedPath }: Props) => {
setLoadingFolders((prev) => new Set(prev).add(folderPath)); setLoadingFolders((prev) => new Set(prev).add(folderPath));
try { try {
const result = await queryClient.fetchQuery( const result = await queryClient.ensureQueryData(
browseFilesystemOptions({ browseFilesystemOptions({
query: { path: folderPath }, query: { path: folderPath },
}), }),

View File

@@ -17,7 +17,7 @@ export function EmptyState(props: EmptyStateProps) {
<div className="absolute inset-0 animate-pulse"> <div className="absolute inset-0 animate-pulse">
<div className="w-32 h-32 rounded-full bg-primary/10 blur-2xl" /> <div className="w-32 h-32 rounded-full bg-primary/10 blur-2xl" />
</div> </div>
<div className="relative flex items-center justify-center w-32 h-32 rounded-full bg-gradient-to-br from-primary/20 to-primary/5 border-2 border-primary/20"> <div className="relative flex items-center justify-center w-32 h-32 rounded-full bg-linear-to-br from-primary/20 to-primary/5 border-2 border-primary/20">
<Cicon className="w-16 h-16 text-primary/70" strokeWidth={1.5} /> <Cicon className="w-16 h-16 text-primary/70" strokeWidth={1.5} />
</div> </div>
</div> </div>

View File

@@ -10,8 +10,8 @@
import { ChevronDown, ChevronRight, File as FileIcon, Folder as FolderIcon, FolderOpen, Loader2 } from "lucide-react"; 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 { memo, type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
import { cn } from "~/lib/utils"; import { cn } from "~/client/lib/utils";
import { Checkbox } from "~/components/ui/checkbox"; import { Checkbox } from "~/client/components/ui/checkbox";
const NODE_PADDING_LEFT = 12; const NODE_PADDING_LEFT = 12;

View File

@@ -1,5 +1,5 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { cn } from "~/lib/utils"; import { cn } from "~/client/lib/utils";
interface GridBackgroundProps { interface GridBackgroundProps {
children: ReactNode; children: ReactNode;
@@ -12,9 +12,9 @@ export function GridBackground({ children, className, containerClassName }: Grid
<div <div
className={cn( className={cn(
"relative min-h-full w-full overflow-x-hidden", "relative min-h-full w-full overflow-x-hidden",
"[background-size:20px_20px] sm:[background-size:40px_40px]", "bg-size-[20px_20px] sm:bg-size-[40px_40px]",
"[background-image:linear-gradient(to_right,#e4e4e7_1px,transparent_1px),linear-gradient(to_bottom,#e4e4e7_1px,transparent_1px)]", "bg-[linear-gradient(to_right,#e4e4e7_1px,transparent_1px),linear-gradient(to_bottom,#e4e4e7_1px,transparent_1px)]",
"dark:[background-image:linear-gradient(to_right,#262626_1px,transparent_1px),linear-gradient(to_bottom,#262626_1px,transparent_1px)]", "dark:bg-[linear-gradient(to_right,#262626_1px,transparent_1px),linear-gradient(to_bottom,#262626_1px,transparent_1px)]",
containerClassName, containerClassName,
)} )}
> >

View File

@@ -2,7 +2,6 @@ import { useMutation } from "@tanstack/react-query";
import { LifeBuoy } from "lucide-react"; import { LifeBuoy } from "lucide-react";
import { Outlet, redirect, useNavigate } from "react-router"; import { Outlet, redirect, useNavigate } from "react-router";
import { toast } from "sonner"; import { toast } from "sonner";
import { logoutMutation } from "~/api-client/@tanstack/react-query.gen";
import { appContext } from "~/context"; import { appContext } from "~/context";
import { authMiddleware } from "~/middleware/auth"; import { authMiddleware } from "~/middleware/auth";
import type { Route } from "./+types/layout"; import type { Route } from "./+types/layout";
@@ -11,6 +10,7 @@ import { GridBackground } from "./grid-background";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
import { SidebarProvider, SidebarTrigger } from "./ui/sidebar"; import { SidebarProvider, SidebarTrigger } from "./ui/sidebar";
import { AppSidebar } from "./app-sidebar"; import { AppSidebar } from "./app-sidebar";
import { logoutMutation } from "../api-client/@tanstack/react-query.gen";
export const clientMiddleware = [authMiddleware]; export const clientMiddleware = [authMiddleware];
@@ -42,7 +42,7 @@ export default function Layout({ loaderData }: Route.ComponentProps) {
<SidebarProvider defaultOpen={true}> <SidebarProvider defaultOpen={true}>
<AppSidebar /> <AppSidebar />
<div className="w-full relative flex flex-col h-screen overflow-hidden"> <div className="w-full relative flex flex-col h-screen overflow-hidden">
<header className="z-50 bg-card-header border-b border-border/50 flex-shrink-0"> <header className="z-50 bg-card-header border-b border-border/50 shrink-0">
<div className="flex items-center justify-between py-3 sm:py-4 px-2 sm:px-8 mx-auto container"> <div className="flex items-center justify-between py-3 sm:py-4 px-2 sm:px-8 mx-auto container">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<SidebarTrigger /> <SidebarTrigger />

View File

@@ -1,4 +1,4 @@
import { cn } from "~/lib/utils"; import { cn } from "~/client/lib/utils";
import { Switch } from "./ui/switch"; import { Switch } from "./ui/switch";
type Props = { type Props = {

View File

@@ -1,5 +1,5 @@
import type { RepositoryBackend } from "@ironmount/schemas/restic";
import { Database, HardDrive, Cloud } from "lucide-react"; import { Database, HardDrive, Cloud } from "lucide-react";
import type { RepositoryBackend } from "~/schemas/restic";
type Props = { type Props = {
backend: RepositoryBackend; backend: RepositoryBackend;
@@ -12,6 +12,8 @@ export const RepositoryIcon = ({ backend, className = "h-4 w-4" }: Props) => {
return <HardDrive className={className} />; return <HardDrive className={className} />;
case "s3": case "s3":
return <Cloud className={className} />; return <Cloud className={className} />;
case "gcs":
return <Cloud className={className} />;
default: default:
return <Database className={className} />; return <Database className={className} />;
} }

View File

@@ -1,10 +1,10 @@
import { Calendar, Clock, Database, FolderTree, HardDrive } from "lucide-react"; import { Calendar, Clock, Database, FolderTree, HardDrive } from "lucide-react";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import type { ListSnapshotsResponse } from "~/api-client/types.gen"; import { ByteSize } from "~/client/components/bytes-size";
import { ByteSize } from "~/components/bytes-size"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/client/components/ui/table";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/components/ui/table"; import { Tooltip, TooltipContent, TooltipTrigger } from "~/client/components/ui/tooltip";
import { Tooltip, TooltipContent, TooltipTrigger } from "~/components/ui/tooltip";
import { formatDuration } from "~/utils/utils"; import { formatDuration } from "~/utils/utils";
import type { ListSnapshotsResponse } from "../api-client";
type Snapshot = ListSnapshotsResponse[number]; type Snapshot = ListSnapshotsResponse[number];

View File

@@ -1,5 +1,5 @@
import type { VolumeStatus } from "~/lib/types"; import type { VolumeStatus } from "~/client/lib/types";
import { cn } from "~/lib/utils"; import { cn } from "~/client/lib/utils";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
export const StatusDot = ({ status }: { status: VolumeStatus }) => { export const StatusDot = ({ status }: { status: VolumeStatus }) => {
@@ -38,10 +38,7 @@ export const StatusDot = ({ status }: { status: VolumeStatus }) => {
)} )}
/> />
)} )}
<span <span className={cn("relative inline-flex size-3 rounded-full", `${statusMapping.color}`)} />
aria-label={status}
className={cn("relative inline-flex size-3 rounded-full", `${statusMapping.color}`)}
/>
</span> </span>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>

View File

@@ -1,7 +1,7 @@
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"; import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
import type * as React from "react"; import type * as React from "react";
import { buttonVariants } from "~/components/ui/button"; import { buttonVariants } from "~/client/components/ui/button";
import { cn } from "~/lib/utils"; import { cn } from "~/client/lib/utils";
function AlertDialog({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Root>) { function AlertDialog({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />; return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;

View File

@@ -1,7 +1,7 @@
import * as React from "react"; import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority"; import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "~/lib/utils"; import { cn } from "~/client/lib/utils";
const alertVariants = cva( const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7", "relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",

View File

@@ -2,7 +2,7 @@ import type * as React from "react";
import { Slot } from "@radix-ui/react-slot"; import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority"; import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "~/lib/utils"; import { cn } from "~/client/lib/utils";
const badgeVariants = cva( const badgeVariants = cva(
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", "inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",

View File

@@ -0,0 +1,92 @@
import type * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { ChevronRight, MoreHorizontal } from "lucide-react";
import { cn } from "~/client/lib/utils";
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />;
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm wrap-break-words sm:gap-2.5",
className,
)}
{...props}
/>
);
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return <li data-slot="breadcrumb-item" className={cn("inline-flex items-center gap-1.5", className)} {...props} />;
}
function BreadcrumbLink({
asChild,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : "a";
return (
<Comp data-slot="breadcrumb-link" className={cn("hover:text-foreground transition-colors", className)} {...props} />
);
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn("text-foreground font-normal", className)}
{...props}
/>
);
}
function BreadcrumbSeparator({ children, className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
);
}
function BreadcrumbEllipsis({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="size-4" />
<span className="sr-only">More</span>
</span>
);
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
};

View File

@@ -3,7 +3,7 @@ import { cva, type VariantProps } from "class-variance-authority";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import type * as React from "react"; import type * as React from "react";
import { cn } from "~/lib/utils"; import { cn } from "~/client/lib/utils";
const buttonVariants = cva( const buttonVariants = cva(
"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", "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",

View File

@@ -1,6 +1,6 @@
import type * as React from "react"; import type * as React from "react";
import { cn } from "~/lib/utils"; import { cn } from "~/client/lib/utils";
function Card({ className, children, ...props }: React.ComponentProps<"div">) { function Card({ className, children, ...props }: React.ComponentProps<"div">) {
return ( return (

View File

@@ -3,7 +3,7 @@
import * as React from "react"; import * as React from "react";
import * as RechartsPrimitive from "recharts"; import * as RechartsPrimitive from "recharts";
import { cn } from "~/lib/utils"; import { cn } from "~/client/lib/utils";
// Format: { THEME_NAME: CSS_SELECTOR } // Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const; const THEMES = { light: "", dark: ".dark" } as const;

View File

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

View File

@@ -0,0 +1,121 @@
import type * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { XIcon } from "lucide-react";
import { cn } from "~/client/lib/utils";
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className,
)}
{...props}
/>
);
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
);
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
{...props}
/>
);
}
function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
);
}
function DialogDescription({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};

View File

@@ -1,5 +1,5 @@
import * as React from "react"; import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label"; import type * as LabelPrimitive from "@radix-ui/react-label";
import { Slot } from "@radix-ui/react-slot"; import { Slot } from "@radix-ui/react-slot";
import { import {
Controller, Controller,
@@ -11,8 +11,8 @@ import {
type FieldValues, type FieldValues,
} from "react-hook-form"; } from "react-hook-form";
import { cn } from "~/lib/utils"; import { cn } from "~/client/lib/utils";
import { Label } from "~/components/ui/label"; import { Label } from "~/client/components/ui/label";
const Form = FormProvider; const Form = FormProvider;

View File

@@ -1,6 +1,6 @@
import type * as React from "react"; import type * as React from "react";
import { cn } from "~/lib/utils"; import { cn } from "~/client/lib/utils";
function Input({ className, type, ...props }: React.ComponentProps<"input">) { function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return ( return (

View File

@@ -0,0 +1,21 @@
"use client";
import type * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cn } from "~/client/lib/utils";
function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className,
)}
{...props}
/>
);
}
export { Label };

View File

@@ -1,7 +1,7 @@
import * as React from "react"; import type * as React from "react";
import * as ProgressPrimitive from "@radix-ui/react-progress"; import * as ProgressPrimitive from "@radix-ui/react-progress";
import { cn } from "~/lib/utils"; import { cn } from "~/client/lib/utils";
function Progress({ className, value, ...props }: React.ComponentProps<typeof ProgressPrimitive.Root>) { function Progress({ className, value, ...props }: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return ( return (

View File

@@ -0,0 +1,46 @@
import type * as React from "react";
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
import { cn } from "~/client/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 };

View File

@@ -1,24 +1,18 @@
import * as React from "react"; import type * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select"; import * as SelectPrimitive from "@radix-ui/react-select";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"; import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { cn } from "~/lib/utils"; import { cn } from "~/client/lib/utils";
function Select({ function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />; return <SelectPrimitive.Root data-slot="select" {...props} />;
} }
function SelectGroup({ function SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />; return <SelectPrimitive.Group data-slot="select-group" {...props} />;
} }
function SelectValue({ function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />; return <SelectPrimitive.Value data-slot="select-value" {...props} />;
} }
@@ -83,10 +77,7 @@ function SelectContent({
); );
} }
function SelectLabel({ function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) {
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return ( return (
<SelectPrimitive.Label <SelectPrimitive.Label
data-slot="select-label" data-slot="select-label"
@@ -96,11 +87,7 @@ function SelectLabel({
); );
} }
function SelectItem({ function SelectItem({ className, children, ...props }: React.ComponentProps<typeof SelectPrimitive.Item>) {
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return ( return (
<SelectPrimitive.Item <SelectPrimitive.Item
data-slot="select-item" data-slot="select-item"
@@ -120,10 +107,7 @@ function SelectItem({
); );
} }
function SelectSeparator({ function SelectSeparator({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Separator>) {
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return ( return (
<SelectPrimitive.Separator <SelectPrimitive.Separator
data-slot="select-separator" data-slot="select-separator"
@@ -133,17 +117,11 @@ function SelectSeparator({
); );
} }
function SelectScrollUpButton({ function SelectScrollUpButton({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return ( return (
<SelectPrimitive.ScrollUpButton <SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button" data-slot="select-scroll-up-button"
className={cn( className={cn("flex cursor-default items-center justify-center py-1", className)}
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props} {...props}
> >
<ChevronUpIcon className="size-4" /> <ChevronUpIcon className="size-4" />
@@ -158,10 +136,7 @@ function SelectScrollDownButton({
return ( return (
<SelectPrimitive.ScrollDownButton <SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button" data-slot="select-scroll-down-button"
className={cn( className={cn("flex cursor-default items-center justify-center py-1", className)}
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props} {...props}
> >
<ChevronDownIcon className="size-4" /> <ChevronDownIcon className="size-4" />

View File

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

View File

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

View File

@@ -5,14 +5,14 @@ import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority"; import { cva, type VariantProps } from "class-variance-authority";
import { PanelLeftIcon } from "lucide-react"; import { PanelLeftIcon } from "lucide-react";
import { useIsMobile } from "~/hooks/use-mobile"; import { useIsMobile } from "~/client/hooks/use-mobile";
import { cn } from "~/lib/utils"; import { cn } from "~/client/lib/utils";
import { Button } from "~/components/ui/button"; import { Button } from "~/client/components/ui/button";
import { Input } from "~/components/ui/input"; import { Input } from "~/client/components/ui/input";
import { Separator } from "~/components/ui/separator"; import { Separator } from "~/client/components/ui/separator";
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "~/components/ui/sheet"; import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "~/client/components/ui/sheet";
import { Skeleton } from "~/components/ui/skeleton"; import { Skeleton } from "~/client/components/ui/skeleton";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "~/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "~/client/components/ui/tooltip";
const SIDEBAR_COOKIE_NAME = "sidebar_state"; const SIDEBAR_COOKIE_NAME = "sidebar_state";
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;

View File

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

View File

@@ -1,7 +1,7 @@
import * as SwitchPrimitive from "@radix-ui/react-switch"; import * as SwitchPrimitive from "@radix-ui/react-switch";
import type * as React from "react"; import type * as React from "react";
import { cn } from "~/lib/utils"; import { cn } from "~/client/lib/utils";
function Switch({ className, ...props }: React.ComponentProps<typeof SwitchPrimitive.Root>) { function Switch({ className, ...props }: React.ComponentProps<typeof SwitchPrimitive.Root>) {
return ( return (

View File

@@ -0,0 +1,73 @@
import type * as React from "react";
import { cn } from "~/client/lib/utils";
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div data-slot="table-container" className="relative w-full overflow-x-auto">
<table data-slot="table" className={cn("w-full caption-bottom text-sm", className)} {...props} />
</div>
);
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return <thead data-slot="table-header" className={cn("[&_tr]:border-b", className)} {...props} />;
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return <tbody data-slot="table-body" className={cn("[&_tr:last-child]:border-0", className)} {...props} />;
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn("bg-muted/50 border-t font-medium [&>tr]:last:border-b-0", className)}
{...props}
/>
);
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn("hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors", className)}
{...props}
/>
);
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className,
)}
{...props}
/>
);
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className,
)}
{...props}
/>
);
}
function TableCaption({ className, ...props }: React.ComponentProps<"caption">) {
return (
<caption data-slot="table-caption" className={cn("text-muted-foreground mt-4 text-sm", className)} {...props} />
);
}
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption };

View File

@@ -1,7 +1,7 @@
import * as TabsPrimitive from "@radix-ui/react-tabs"; import * as TabsPrimitive from "@radix-ui/react-tabs";
import type * as React from "react"; import type * as React from "react";
import { cn } from "~/lib/utils"; import { cn } from "~/client/lib/utils";
function Tabs({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Root>) { function Tabs({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Root>) {
return <TabsPrimitive.Root data-slot="tabs" className={cn("flex flex-col gap-2", className)} {...props} />; return <TabsPrimitive.Root data-slot="tabs" className={cn("flex flex-col gap-2", className)} {...props} />;

View File

@@ -1,6 +1,6 @@
import * as React from "react"; import type * as React from "react";
import { cn } from "~/lib/utils"; import { cn } from "~/client/lib/utils";
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) { function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return ( return (

View File

@@ -0,0 +1,46 @@
import type * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "~/client/lib/utils";
function TooltipProvider({ delayDuration = 0, ...props }: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return <TooltipPrimitive.Provider data-slot="tooltip-provider" delayDuration={delayDuration} {...props} />;
}
function Tooltip({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
);
}
function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className,
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
);
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };

View File

@@ -1,8 +1,8 @@
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { FolderOpen } from "lucide-react"; import { FolderOpen } from "lucide-react";
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { listFilesOptions } from "~/api-client/@tanstack/react-query.gen"; import { FileTree } from "~/client/components/file-tree";
import { FileTree } from "~/components/file-tree"; import { listFilesOptions } from "../api-client/@tanstack/react-query.gen";
interface FileEntry { interface FileEntry {
name: string; name: string;
@@ -72,7 +72,7 @@ export const VolumeFileBrowser = ({
setLoadingFolders((prev) => new Set(prev).add(folderPath)); setLoadingFolders((prev) => new Set(prev).add(folderPath));
try { try {
const result = await queryClient.fetchQuery( const result = await queryClient.ensureQueryData(
listFilesOptions({ listFilesOptions({
path: { name: volumeName }, path: { name: volumeName },
query: { path: folderPath }, query: { path: folderPath },
@@ -101,7 +101,7 @@ export const VolumeFileBrowser = ({
} }
} }
}, },
[volumeName, fetchedFolders, queryClient.fetchQuery], [volumeName, fetchedFolders, queryClient.ensureQueryData],
); );
const handleFolderHover = useCallback( const handleFolderHover = useCallback(

View File

@@ -1,5 +1,5 @@
import type { BackendType } from "@ironmount/schemas";
import { Cloud, Folder, Server, Share2 } from "lucide-react"; import { Cloud, Folder, Server, Share2 } from "lucide-react";
import type { BackendType } from "~/schemas/volumes";
type VolumeIconProps = { type VolumeIconProps = {
backend: BackendType; backend: BackendType;

View File

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

View File

@@ -0,0 +1,18 @@
import { useQuery } from "@tanstack/react-query";
import { getSystemInfoOptions } from "../api-client/@tanstack/react-query.gen";
export function useSystemInfo() {
const { data, isLoading, error } = useQuery({
...getSystemInfoOptions(),
staleTime: 5 * 60 * 1000,
gcTime: 10 * 60 * 1000,
refetchOnWindowFocus: false,
});
return {
capabilities: data?.capabilities ?? { docker: false, rclone: false },
isLoading,
error,
systemInfo: data,
};
}

View File

@@ -4,7 +4,7 @@ import type {
GetRepositoryResponse, GetRepositoryResponse,
GetVolumeResponse, GetVolumeResponse,
ListSnapshotsResponse, ListSnapshotsResponse,
} from "~/api-client"; } from "../api-client";
export type Volume = GetVolumeResponse["volume"]; export type Volume = GetVolumeResponse["volume"];
export type StatFs = GetVolumeResponse["statfs"]; export type StatFs = GetVolumeResponse["statfs"];

View File

@@ -3,14 +3,14 @@ import { AlertTriangle, Download } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { toast } from "sonner"; import { toast } from "sonner";
import { downloadResticPasswordMutation } from "~/api-client/@tanstack/react-query.gen"; import { AuthLayout } from "~/client/components/auth-layout";
import { AuthLayout } from "~/components/auth-layout"; import { Alert, AlertDescription, AlertTitle } from "~/client/components/ui/alert";
import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert"; import { Button } from "~/client/components/ui/button";
import { Button } from "~/components/ui/button"; import { Input } from "~/client/components/ui/input";
import { Input } from "~/components/ui/input"; import { Label } from "~/client/components/ui/label";
import { Label } from "~/components/ui/label";
import { authMiddleware } from "~/middleware/auth"; import { authMiddleware } from "~/middleware/auth";
import type { Route } from "./+types/download-recovery-key"; import type { Route } from "./+types/download-recovery-key";
import { downloadResticPasswordMutation } from "~/client/api-client/@tanstack/react-query.gen";
export const clientMiddleware = [authMiddleware]; export const clientMiddleware = [authMiddleware];

View File

@@ -4,13 +4,13 @@ import { type } from "arktype";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { toast } from "sonner"; import { toast } from "sonner";
import { loginMutation } from "~/api-client/@tanstack/react-query.gen"; import { AuthLayout } from "~/client/components/auth-layout";
import { AuthLayout } from "~/components/auth-layout"; import { Button } from "~/client/components/ui/button";
import { Button } from "~/components/ui/button"; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "~/client/components/ui/form";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "~/components/ui/form"; import { Input } from "~/client/components/ui/input";
import { Input } from "~/components/ui/input";
import { authMiddleware } from "~/middleware/auth"; import { authMiddleware } from "~/middleware/auth";
import type { Route } from "./+types/login"; import type { Route } from "./+types/login";
import { loginMutation } from "~/client/api-client/@tanstack/react-query.gen";
export const clientMiddleware = [authMiddleware]; export const clientMiddleware = [authMiddleware];

View File

@@ -4,13 +4,21 @@ import { type } from "arktype";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { toast } from "sonner"; import { toast } from "sonner";
import { registerMutation } from "~/api-client/@tanstack/react-query.gen"; import {
import { AuthLayout } from "~/components/auth-layout"; Form,
import { Button } from "~/components/ui/button"; FormControl,
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "~/components/ui/form"; FormDescription,
import { Input } from "~/components/ui/input"; FormField,
FormItem,
FormLabel,
FormMessage,
} from "~/client/components/ui/form";
import { authMiddleware } from "~/middleware/auth"; import { authMiddleware } from "~/middleware/auth";
import type { Route } from "./+types/onboarding"; import type { Route } from "./+types/onboarding";
import { AuthLayout } from "~/client/components/auth-layout";
import { Input } from "~/client/components/ui/input";
import { Button } from "~/client/components/ui/button";
import { registerMutation } from "~/client/api-client/@tanstack/react-query.gen";
export const clientMiddleware = [authMiddleware]; export const clientMiddleware = [authMiddleware];

View File

@@ -1,8 +1,8 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { ByteSize, formatBytes } from "~/components/bytes-size"; import { ByteSize, formatBytes } from "~/client/components/bytes-size";
import { Card } from "~/components/ui/card"; import { Card } from "~/client/components/ui/card";
import { Progress } from "~/components/ui/progress"; import { Progress } from "~/client/components/ui/progress";
import { type BackupProgressEvent, useServerEvents } from "~/hooks/use-server-events"; import { type BackupProgressEvent, useServerEvents } from "~/client/hooks/use-server-events";
import { formatDuration } from "~/utils/utils"; import { formatDuration } from "~/utils/utils";
type Props = { type Props = {

View File

@@ -1,5 +1,5 @@
import { cn } from "~/lib/utils"; import { cn } from "~/client/lib/utils";
import { Tooltip, TooltipContent, TooltipTrigger } from "~/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "~/client/components/ui/tooltip";
type BackupStatus = "active" | "paused" | "error" | "in_progress"; type BackupStatus = "active" | "paused" | "error" | "in_progress";
@@ -7,7 +7,11 @@ export const BackupStatusDot = ({
enabled, enabled,
hasError, hasError,
isInProgress, isInProgress,
}: { enabled: boolean; hasError?: boolean; isInProgress?: boolean }) => { }: {
enabled: boolean;
hasError?: boolean;
isInProgress?: boolean;
}) => {
let status: BackupStatus = "paused"; let status: BackupStatus = "paused";
if (isInProgress) { if (isInProgress) {
status = "in_progress"; status = "in_progress";

View File

@@ -3,15 +3,23 @@ import { useQuery } from "@tanstack/react-query";
import { type } from "arktype"; import { type } from "arktype";
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { listRepositoriesOptions } from "~/api-client/@tanstack/react-query.gen"; import { listRepositoriesOptions } from "~/client/api-client/@tanstack/react-query.gen";
import { RepositoryIcon } from "~/components/repository-icon"; import { RepositoryIcon } from "~/client/components/repository-icon";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "~/components/ui/form"; import {
import { Input } from "~/components/ui/input"; Form,
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select"; FormControl,
import { Textarea } from "~/components/ui/textarea"; FormDescription,
import { VolumeFileBrowser } from "~/components/volume-file-browser"; FormField,
import type { BackupSchedule, Volume } from "~/lib/types"; FormItem,
FormLabel,
FormMessage,
} from "~/client/components/ui/form";
import { Input } from "~/client/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
import { Textarea } from "~/client/components/ui/textarea";
import { VolumeFileBrowser } from "~/client/components/volume-file-browser";
import type { BackupSchedule, Volume } from "~/client/lib/types";
import { deepClean } from "~/utils/object"; import { deepClean } from "~/utils/object";
const internalFormSchema = type({ const internalFormSchema = type({
@@ -128,7 +136,7 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
<Form {...form}> <Form {...form}>
<form <form
onSubmit={form.handleSubmit(handleSubmit)} onSubmit={form.handleSubmit(handleSubmit)}
className="grid gap-4 xl:grid-cols-[minmax(0,_2.3fr)_minmax(320px,_1fr)]" className="grid gap-4 xl:grid-cols-[minmax(0,2.3fr)_minmax(320px,1fr)]"
id={formId} id={formId}
> >
<div className="grid gap-4"> <div className="grid gap-4">

View File

@@ -1,8 +1,8 @@
import { Pencil, Play, Square, Trash2 } from "lucide-react"; import { Pencil, Play, Square, Trash2 } from "lucide-react";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { OnOff } from "~/components/onoff"; import { OnOff } from "~/client/components/onoff";
import { Button } from "~/components/ui/button"; import { Button } from "~/client/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@@ -11,8 +11,8 @@ import {
AlertDialogDescription, AlertDialogDescription,
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
} from "~/components/ui/alert-dialog"; } from "~/client/components/ui/alert-dialog";
import type { BackupSchedule } from "~/lib/types"; import type { BackupSchedule } from "~/client/lib/types";
import { BackupProgressCard } from "./backup-progress-card"; import { BackupProgressCard } from "./backup-progress-card";
type Props = { type Props = {

View File

@@ -1,12 +1,11 @@
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { FileIcon } from "lucide-react"; import { FileIcon } from "lucide-react";
import { listSnapshotFilesOptions, restoreSnapshotMutation } from "~/api-client/@tanstack/react-query.gen"; import { FileTree, type FileEntry } from "~/client/components/file-tree";
import { FileTree, type FileEntry } from "~/components/file-tree"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card"; import { Button } from "~/client/components/ui/button";
import { Button } from "~/components/ui/button"; import { Checkbox } from "~/client/components/ui/checkbox";
import { Checkbox } from "~/components/ui/checkbox"; import { Label } from "~/client/components/ui/label";
import { Label } from "~/components/ui/label";
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@@ -16,10 +15,11 @@ import {
AlertDialogFooter, AlertDialogFooter,
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
} from "~/components/ui/alert-dialog"; } from "~/client/components/ui/alert-dialog";
import { Tooltip, TooltipContent, TooltipTrigger } from "~/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "~/client/components/ui/tooltip";
import type { Snapshot, Volume } from "~/lib/types"; import type { Snapshot, Volume } from "~/client/lib/types";
import { toast } from "sonner"; import { toast } from "sonner";
import { listSnapshotFilesOptions, restoreSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen";
interface Props { interface Props {
snapshot: Snapshot; snapshot: Snapshot;
@@ -41,7 +41,7 @@ export const SnapshotFileBrowser = (props: Props) => {
const [showRestoreDialog, setShowRestoreDialog] = useState(false); const [showRestoreDialog, setShowRestoreDialog] = useState(false);
const [deleteExtraFiles, setDeleteExtraFiles] = useState(false); const [deleteExtraFiles, setDeleteExtraFiles] = useState(false);
const volumeBasePath = snapshot.paths[0]?.match(/^(.*?_data)(\/|$)/)?.[1] || ""; const volumeBasePath = snapshot.paths[0]?.match(/^(.*?_data)(\/|$)/)?.[1] || "/";
const { data: filesData, isLoading: filesLoading } = useQuery({ const { data: filesData, isLoading: filesLoading } = useQuery({
...listSnapshotFilesOptions({ ...listSnapshotFilesOptions({
@@ -104,7 +104,7 @@ export const SnapshotFileBrowser = (props: Props) => {
try { try {
const fullPath = addBasePath(folderPath); const fullPath = addBasePath(folderPath);
const result = await queryClient.fetchQuery( const result = await queryClient.ensureQueryData(
listSnapshotFilesOptions({ listSnapshotFilesOptions({
path: { name: repositoryName, snapshotId: snapshot.short_id }, path: { name: repositoryName, snapshotId: snapshot.short_id },
query: { path: fullPath }, query: { path: fullPath },
@@ -145,12 +145,12 @@ export const SnapshotFileBrowser = (props: Props) => {
if (!fetchedFolders.has(folderPath) && !loadingFolders.has(folderPath)) { if (!fetchedFolders.has(folderPath) && !loadingFolders.has(folderPath)) {
const fullPath = addBasePath(folderPath); const fullPath = addBasePath(folderPath);
queryClient.prefetchQuery( queryClient.prefetchQuery({
listSnapshotFilesOptions({ ...listSnapshotFilesOptions({
path: { name: repositoryName, snapshotId: snapshot.short_id }, path: { name: repositoryName, snapshotId: snapshot.short_id },
query: { path: fullPath }, query: { path: fullPath },
}), }),
); });
} }
}, },
[repositoryName, snapshot, fetchedFolders, loadingFolders, queryClient, addBasePath], [repositoryName, snapshot, fetchedFolders, loadingFolders, queryClient, addBasePath],

View File

@@ -1,8 +1,8 @@
import type { ListSnapshotsResponse } from "~/api-client/types.gen"; import { cn } from "~/client/lib/utils";
import { cn } from "~/lib/utils"; import { Card } from "~/client/components/ui/card";
import { Card } from "~/components/ui/card"; import { ByteSize } from "~/client/components/bytes-size";
import { ByteSize } from "~/components/bytes-size";
import { useEffect } from "react"; import { useEffect } from "react";
import type { ListSnapshotsResponse } from "~/client/api-client";
interface Props { interface Props {
snapshots: ListSnapshotsResponse; snapshots: ListSnapshotsResponse;
@@ -56,7 +56,7 @@ export const SnapshotTimeline = (props: Props) => {
<div className="w-full bg-card"> <div className="w-full bg-card">
<div className="relative flex items-center"> <div className="relative flex items-center">
<div className="flex-1 overflow-hidden"> <div className="flex-1 overflow-hidden">
<div className="flex gap-4 overflow-x-auto pb-2 [&>:first-child]:ml-2 [&>:last-child]:mr-2"> <div className="flex gap-4 overflow-x-auto pb-2 *:first:ml-2 *:last:mr-2">
{snapshots.map((snapshot, index) => { {snapshots.map((snapshot, index) => {
const date = new Date(snapshot.time); const date = new Date(snapshot.time);
const isSelected = snapshotId === snapshot.short_id; const isSelected = snapshotId === snapshot.short_id;

View File

@@ -2,7 +2,7 @@ import { useId, useState } from "react";
import { useQuery, useMutation } from "@tanstack/react-query"; import { useQuery, useMutation } from "@tanstack/react-query";
import { redirect, useNavigate } from "react-router"; import { redirect, useNavigate } from "react-router";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "~/components/ui/button"; import { Button } from "~/client/components/ui/button";
import { import {
getBackupScheduleOptions, getBackupScheduleOptions,
runBackupNowMutation, runBackupNowMutation,
@@ -10,15 +10,15 @@ import {
listSnapshotsOptions, listSnapshotsOptions,
updateBackupScheduleMutation, updateBackupScheduleMutation,
stopBackupMutation, stopBackupMutation,
} from "~/api-client/@tanstack/react-query.gen"; } from "~/client/api-client/@tanstack/react-query.gen";
import { parseError } from "~/lib/errors"; import { parseError } from "~/client/lib/errors";
import { getCronExpression } from "~/utils/utils"; import { getCronExpression } from "~/utils/utils";
import { CreateScheduleForm, type BackupScheduleFormValues } from "../components/create-schedule-form"; import { CreateScheduleForm, type BackupScheduleFormValues } from "../components/create-schedule-form";
import { ScheduleSummary } from "../components/schedule-summary"; import { ScheduleSummary } from "../components/schedule-summary";
import { getBackupSchedule } from "~/api-client";
import type { Route } from "./+types/backup-details"; import type { Route } from "./+types/backup-details";
import { SnapshotFileBrowser } from "../components/snapshot-file-browser"; import { SnapshotFileBrowser } from "../components/snapshot-file-browser";
import { SnapshotTimeline } from "../components/snapshot-timeline"; import { SnapshotTimeline } from "../components/snapshot-timeline";
import { getBackupSchedule } from "~/client/api-client";
export function meta(_: Route.MetaArgs) { export function meta(_: Route.MetaArgs) {
return [ return [

View File

@@ -1,13 +1,13 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { CalendarClock, Database, HardDrive, Plus } from "lucide-react"; import { CalendarClock, Database, HardDrive, Plus } from "lucide-react";
import { Link } from "react-router"; import { Link } from "react-router";
import { listBackupSchedules } from "~/api-client";
import { listBackupSchedulesOptions } from "~/api-client/@tanstack/react-query.gen";
import { BackupStatusDot } from "../components/backup-status-dot"; import { BackupStatusDot } from "../components/backup-status-dot";
import { EmptyState } from "~/components/empty-state"; import { EmptyState } from "~/client/components/empty-state";
import { Button } from "~/components/ui/button"; import { Button } from "~/client/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
import type { Route } from "./+types/backups"; import type { Route } from "./+types/backups";
import { listBackupSchedules } from "~/client/api-client";
import { listBackupSchedulesOptions } from "~/client/api-client/@tanstack/react-query.gen";
export function meta(_: Route.MetaArgs) { export function meta(_: Route.MetaArgs) {
return [ return [
@@ -68,7 +68,7 @@ export default function Backups({ loaderData }: Route.ComponentProps) {
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2 flex-1 min-w-0"> <div className="flex items-center gap-2 flex-1 min-w-0">
<HardDrive className="h-5 w-5 text-muted-foreground flex-shrink-0" /> <HardDrive className="h-5 w-5 text-muted-foreground shrink-0" />
<CardTitle className="text-lg truncate"> <CardTitle className="text-lg truncate">
Volume <span className="text-strong-accent">{schedule.volume.name}</span> Volume <span className="text-strong-accent">{schedule.volume.name}</span>
</CardTitle> </CardTitle>

View File

@@ -1,5 +1,5 @@
import { useId, useState } from "react"; import { useId, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { Database, HardDrive } from "lucide-react"; import { Database, HardDrive } from "lucide-react";
import { Link, useNavigate } from "react-router"; import { Link, useNavigate } from "react-router";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -7,16 +7,16 @@ import {
createBackupScheduleMutation, createBackupScheduleMutation,
listRepositoriesOptions, listRepositoriesOptions,
listVolumesOptions, listVolumesOptions,
} from "~/api-client/@tanstack/react-query.gen"; } from "~/client/api-client/@tanstack/react-query.gen";
import { Button } from "~/components/ui/button"; import { Button } from "~/client/components/ui/button";
import { Card, CardContent } from "~/components/ui/card"; import { Card, CardContent } from "~/client/components/ui/card";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
import { parseError } from "~/lib/errors"; import { parseError } from "~/client/lib/errors";
import { EmptyState } from "~/components/empty-state"; import { EmptyState } from "~/client/components/empty-state";
import { getCronExpression } from "~/utils/utils"; import { getCronExpression } from "~/utils/utils";
import { CreateScheduleForm, type BackupScheduleFormValues } from "../components/create-schedule-form"; import { CreateScheduleForm, type BackupScheduleFormValues } from "../components/create-schedule-form";
import type { Route } from "./+types/create-backup"; import type { Route } from "./+types/create-backup";
import { listRepositories, listVolumes } from "~/api-client"; import { listRepositories, listVolumes } from "~/client/api-client";
export function meta(_: Route.MetaArgs) { export function meta(_: Route.MetaArgs) {
return [ return [
@@ -168,7 +168,7 @@ export default function CreateBackup({ loaderData }: Route.ComponentProps) {
<div className="absolute inset-0 animate-pulse"> <div className="absolute inset-0 animate-pulse">
<div className="w-24 h-24 rounded-full bg-primary/10 blur-2xl" /> <div className="w-24 h-24 rounded-full bg-primary/10 blur-2xl" />
</div> </div>
<div className="relative flex items-center justify-center w-24 h-24 rounded-full bg-gradient-to-br from-primary/20 to-primary/5 border-2 border-primary/20"> <div className="relative flex items-center justify-center w-24 h-24 rounded-full bg-linear-to-br from-primary/20 to-primary/5 border-2 border-primary/20">
<Database className="w-12 h-12 text-primary/70" strokeWidth={1.5} /> <Database className="w-12 h-12 text-primary/70" strokeWidth={1.5} />
</div> </div>
</div> </div>

View File

@@ -2,9 +2,9 @@ import { useMutation } from "@tanstack/react-query";
import { RotateCcw } from "lucide-react"; import { RotateCcw } from "lucide-react";
import { useId, useState } from "react"; import { useId, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { restoreSnapshotMutation } from "~/api-client/@tanstack/react-query.gen"; import { restoreSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen";
import { parseError } from "~/lib/errors"; import { parseError } from "~/client/lib/errors";
import { Button } from "~/components/ui/button"; import { Button } from "~/client/components/ui/button";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -13,8 +13,8 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from "~/components/ui/dialog"; } from "~/client/components/ui/dialog";
import { ScrollArea } from "~/components/ui/scroll-area"; import { ScrollArea } from "~/client/components/ui/scroll-area";
import { RestoreSnapshotForm, type RestoreSnapshotFormValues } from "./restore-snapshot-form"; import { RestoreSnapshotForm, type RestoreSnapshotFormValues } from "./restore-snapshot-form";
type Props = { type Props = {

View File

@@ -1,8 +1,16 @@
import { arktypeResolver } from "@hookform/resolvers/arktype"; import { arktypeResolver } from "@hookform/resolvers/arktype";
import { type } from "arktype"; import { type } from "arktype";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "~/components/ui/form"; import {
import { Input } from "~/components/ui/input"; Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "~/client/components/ui/form";
import { Input } from "~/client/components/ui/input";
const restoreSnapshotFormSchema = type({ const restoreSnapshotFormSchema = type({
path: "string?", path: "string?",

View File

@@ -2,18 +2,18 @@ import { useQuery } from "@tanstack/react-query";
import { Database, RotateCcw } from "lucide-react"; import { Database, RotateCcw } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { listRepositories } from "~/api-client/sdk.gen"; import { listRepositories } from "~/client/api-client/sdk.gen";
import { listRepositoriesOptions } from "~/api-client/@tanstack/react-query.gen"; import { listRepositoriesOptions } from "~/client/api-client/@tanstack/react-query.gen";
import { CreateRepositoryDialog } from "~/components/create-repository-dialog"; import { CreateRepositoryDialog } from "~/client/components/create-repository-dialog";
import { RepositoryIcon } from "~/components/repository-icon"; import { RepositoryIcon } from "~/client/components/repository-icon";
import { Button } from "~/components/ui/button"; import { Button } from "~/client/components/ui/button";
import { Card } from "~/components/ui/card"; import { Card } from "~/client/components/ui/card";
import { Input } from "~/components/ui/input"; import { Input } from "~/client/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/client/components/ui/table";
import type { Route } from "./+types/repositories"; import type { Route } from "./+types/repositories";
import { cn } from "~/lib/utils"; import { cn } from "~/client/lib/utils";
import { EmptyState } from "~/components/empty-state"; import { EmptyState } from "~/client/components/empty-state";
export function meta(_: Route.MetaArgs) { export function meta(_: Route.MetaArgs) {
return [ return [
@@ -79,13 +79,13 @@ export default function Repositories({ loaderData }: Route.ComponentProps) {
<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"> <div className="flex flex-col lg:flex-row items-stretch lg:items-center gap-2 md:justify-between p-4 bg-card-header py-4">
<span className="flex flex-col sm:flex-row items-stretch md:items-center gap-0 flex-wrap "> <span className="flex flex-col sm:flex-row items-stretch md:items-center gap-0 flex-wrap ">
<Input <Input
className="w-full lg:w-[180px] min-w-[180px] mr-[-1px] mt-[-1px]" className="w-full lg:w-[180px] min-w-[180px] -mr-px -mt-px"
placeholder="Search repositories…" placeholder="Search repositories…"
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
/> />
<Select value={statusFilter} onValueChange={setStatusFilter}> <Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-full lg:w-[180px] min-w-[180px] mr-[-1px] mt-[-1px]"> <SelectTrigger className="w-full lg:w-[180px] min-w-[180px] -mr-px -mt-px">
<SelectValue placeholder="All status" /> <SelectValue placeholder="All status" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -95,13 +95,14 @@ export default function Repositories({ loaderData }: Route.ComponentProps) {
</SelectContent> </SelectContent>
</Select> </Select>
<Select value={backendFilter} onValueChange={setBackendFilter}> <Select value={backendFilter} onValueChange={setBackendFilter}>
<SelectTrigger className="w-full lg:w-[180px] min-w-[180px] mt-[-1px]"> <SelectTrigger className="w-full lg:w-[180px] min-w-[180px] -mt-px">
<SelectValue placeholder="All backends" /> <SelectValue placeholder="All backends" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="local">Local</SelectItem> <SelectItem value="local">Local</SelectItem>
<SelectItem value="sftp">SFTP</SelectItem> <SelectItem value="sftp">SFTP</SelectItem>
<SelectItem value="s3">S3</SelectItem> <SelectItem value="s3">S3</SelectItem>
<SelectItem value="gcs">Google Cloud Storage</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
{(searchQuery || statusFilter || backendFilter) && ( {(searchQuery || statusFilter || backendFilter) && (

View File

@@ -7,8 +7,8 @@ import {
doctorRepositoryMutation, doctorRepositoryMutation,
getRepositoryOptions, getRepositoryOptions,
listSnapshotsOptions, listSnapshotsOptions,
} from "~/api-client/@tanstack/react-query.gen"; } from "~/client/api-client/@tanstack/react-query.gen";
import { Button } from "~/components/ui/button"; import { Button } from "~/client/components/ui/button";
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@@ -17,12 +17,12 @@ import {
AlertDialogDescription, AlertDialogDescription,
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
} from "~/components/ui/alert-dialog"; } from "~/client/components/ui/alert-dialog";
import { parseError } from "~/lib/errors"; import { parseError } from "~/client/lib/errors";
import { getRepository } from "~/api-client/sdk.gen"; import { getRepository } from "~/client/api-client/sdk.gen";
import type { Route } from "./+types/repository-details"; import type { Route } from "./+types/repository-details";
import { cn } from "~/lib/utils"; import { cn } from "~/client/lib/utils";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/client/components/ui/tabs";
import { RepositoryInfoTabContent } from "../tabs/info"; import { RepositoryInfoTabContent } from "../tabs/info";
import { RepositorySnapshotsTabContent } from "../tabs/snapshots"; import { RepositorySnapshotsTabContent } from "../tabs/snapshots";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";

View File

@@ -1,10 +1,10 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { redirect, useParams } from "react-router"; import { redirect, useParams } from "react-router";
import { listSnapshotFilesOptions } from "~/api-client/@tanstack/react-query.gen"; import { listSnapshotFilesOptions } from "~/client/api-client/@tanstack/react-query.gen";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
import { RestoreSnapshotDialog } from "../components/restore-snapshot-dialog"; import { RestoreSnapshotDialog } from "../components/restore-snapshot-dialog";
import { SnapshotFileBrowser } from "~/modules/backups/components/snapshot-file-browser"; import { SnapshotFileBrowser } from "~/client/modules/backups/components/snapshot-file-browser";
import { getSnapshotDetails } from "~/api-client"; import { getSnapshotDetails } from "~/client/api-client";
import type { Route } from "./+types/snapshot-details"; import type { Route } from "./+types/snapshot-details";
export function meta({ params }: Route.MetaArgs) { export function meta({ params }: Route.MetaArgs) {

View File

@@ -1,5 +1,5 @@
import { Card } from "~/components/ui/card"; import { Card } from "~/client/components/ui/card";
import type { Repository } from "~/lib/types"; import type { Repository } from "~/client/lib/types";
type Props = { type Props = {
repository: Repository; repository: Repository;
@@ -30,7 +30,7 @@ export const RepositoryInfoTabContent = ({ repository }: Props) => {
</div> </div>
<div> <div>
<div className="text-sm font-medium text-muted-foreground">Created At</div> <div className="text-sm font-medium text-muted-foreground">Created At</div>
<p className="mt-1 text-sm">{new Date(repository.createdAt).toLocaleString()}</p> <p className="mt-1 text-sm">{new Date(repository.createdAt * 1000).toLocaleString()}</p>
</div> </div>
<div> <div>
<div className="text-sm font-medium text-muted-foreground">Last Checked</div> <div className="text-sm font-medium text-muted-foreground">Last Checked</div>

View File

@@ -1,14 +1,13 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Database } from "lucide-react"; import { Database } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { listSnapshotsOptions } from "~/api-client/@tanstack/react-query.gen"; import { listSnapshotsOptions } from "~/client/api-client/@tanstack/react-query.gen";
import { ByteSize } from "~/components/bytes-size"; import { SnapshotsTable } from "~/client/components/snapshots-table";
import { SnapshotsTable } from "~/components/snapshots-table"; import { Button } from "~/client/components/ui/button";
import { Button } from "~/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card"; import { Input } from "~/client/components/ui/input";
import { Input } from "~/components/ui/input"; import { Table, TableBody, TableCell, TableRow } from "~/client/components/ui/table";
import { Table, TableBody, TableCell, TableRow } from "~/components/ui/table"; import type { Repository, Snapshot } from "~/client/lib/types";
import type { Repository, Snapshot } from "~/lib/types";
type Props = { type Props = {
repository: Repository; repository: Repository;
@@ -33,7 +32,7 @@ export const RepositorySnapshotsTabContent = ({ repository }: Props) => {
); );
}); });
const hasNoFilteredSnapshots = !filteredSnapshots?.length && !data.length; const hasNoFilteredSnapshots = !filteredSnapshots?.length;
if (repository.status === "error") { if (repository.status === "error") {
return ( return (
@@ -84,7 +83,7 @@ export const RepositorySnapshotsTabContent = ({ repository }: Props) => {
<div className="absolute inset-0 animate-pulse"> <div className="absolute inset-0 animate-pulse">
<div className="w-32 h-32 rounded-full bg-primary/10 blur-2xl" /> <div className="w-32 h-32 rounded-full bg-primary/10 blur-2xl" />
</div> </div>
<div className="relative flex items-center justify-center w-32 h-32 rounded-full bg-gradient-to-br from-primary/20 to-primary/5 border-2 border-primary/20"> <div className="relative flex items-center justify-center w-32 h-32 rounded-full bg-linear-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} /> <Database className="w-16 h-16 text-primary/70" strokeWidth={1.5} />
</div> </div>
</div> </div>
@@ -111,7 +110,7 @@ export const RepositorySnapshotsTabContent = ({ repository }: Props) => {
</div> </div>
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
<Input <Input
className="w-full lg:w-[240px]" className="w-full lg:w-60"
placeholder="Search snapshots..." placeholder="Search snapshots..."
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
@@ -143,18 +142,6 @@ export const RepositorySnapshotsTabContent = ({ repository }: Props) => {
? "No snapshots match filters." ? "No snapshots match filters."
: `Showing ${filteredSnapshots.length} of ${data.length}`} : `Showing ${filteredSnapshots.length} of ${data.length}`}
</span> </span>
{!hasNoFilteredSnapshots && (
<span>
Total size:&nbsp;
<span className="text-strong-accent font-medium">
<ByteSize
bytes={filteredSnapshots.reduce((sum, s) => sum + s.size, 0)}
base={1024}
maximumFractionDigits={1}
/>
</span>
</span>
)}
</div> </div>
</Card> </Card>
); );

View File

@@ -3,13 +3,8 @@ import { Download, KeyRound, User } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { toast } from "sonner"; import { toast } from "sonner";
import { import { Button } from "~/client/components/ui/button";
changePasswordMutation, import { Card, CardContent, CardDescription, CardTitle } from "~/client/components/ui/card";
downloadResticPasswordMutation,
logoutMutation,
} from "~/api-client/@tanstack/react-query.gen";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardDescription, CardTitle } from "~/components/ui/card";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -18,11 +13,16 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from "~/components/ui/dialog"; } from "~/client/components/ui/dialog";
import { Input } from "~/components/ui/input"; import { Input } from "~/client/components/ui/input";
import { Label } from "~/components/ui/label"; import { Label } from "~/client/components/ui/label";
import { appContext } from "~/context"; import { appContext } from "~/context";
import type { Route } from "./+types/settings"; import type { Route } from "./+types/settings";
import {
changePasswordMutation,
downloadResticPasswordMutation,
logoutMutation,
} from "~/client/api-client/@tanstack/react-query.gen";
export function meta(_: Route.MetaArgs) { export function meta(_: Route.MetaArgs) {
return [ return [

View File

@@ -2,11 +2,11 @@ import { useMutation } from "@tanstack/react-query";
import { formatDistanceToNow } from "date-fns"; import { formatDistanceToNow } from "date-fns";
import { HeartIcon } from "lucide-react"; import { HeartIcon } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { healthCheckVolumeMutation, updateVolumeMutation } from "~/api-client/@tanstack/react-query.gen"; import { healthCheckVolumeMutation, updateVolumeMutation } from "~/client/api-client/@tanstack/react-query.gen";
import { OnOff } from "~/components/onoff"; import { OnOff } from "~/client/components/onoff";
import { Button } from "~/components/ui/button"; import { Button } from "~/client/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
import type { Volume } from "~/lib/types"; import type { Volume } from "~/client/lib/types";
type Props = { type Props = {
volume: Volume; volume: Volume;
@@ -54,7 +54,7 @@ export const HealthchecksCard = ({ volume }: Props) => {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="flex flex-col flex-1 justify-start"> <div className="flex flex-col flex-1 justify-start">
{volume.lastError && <span className="text-sm text-red-500 break-words">{volume.lastError}</span>} {volume.lastError && <span className="text-sm text-red-500 wrap-break-word">{volume.lastError}</span>}
{volume.status === "mounted" && <span className="text-md text-emerald-500">Healthy</span>} {volume.status === "mounted" && <span className="text-md text-emerald-500">Healthy</span>}
{volume.status !== "unmounted" && ( {volume.status !== "unmounted" && (
<span className="text-xs text-muted-foreground mb-4">Checked {timeAgo || "never"}</span> <span className="text-xs text-muted-foreground mb-4">Checked {timeAgo || "never"}</span>

View File

@@ -3,10 +3,10 @@
import { HardDrive, Unplug } from "lucide-react"; import { HardDrive, Unplug } from "lucide-react";
import * as React from "react"; import * as React from "react";
import { Label, Pie, PieChart } from "recharts"; import { Label, Pie, PieChart } from "recharts";
import { ByteSize } from "~/components/bytes-size"; import { ByteSize } from "~/client/components/bytes-size";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
import { type ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from "~/components/ui/chart"; import { type ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from "~/client/components/ui/chart";
import type { StatFs } from "~/lib/types"; import type { StatFs } from "~/client/lib/types";
type Props = { type Props = {
statfs: StatFs; statfs: StatFs;

View File

@@ -2,16 +2,9 @@ import { useMutation, useQuery } from "@tanstack/react-query";
import { useNavigate, useParams, useSearchParams } from "react-router"; import { useNavigate, useParams, useSearchParams } from "react-router";
import { toast } from "sonner"; import { toast } from "sonner";
import { useState } from "react"; import { useState } from "react";
import { import { StatusDot } from "~/client/components/status-dot";
deleteVolumeMutation, import { Button } from "~/client/components/ui/button";
getVolumeOptions, import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/client/components/ui/tabs";
getSystemInfoOptions,
mountVolumeMutation,
unmountVolumeMutation,
} from "~/api-client/@tanstack/react-query.gen";
import { StatusDot } from "~/components/status-dot";
import { Button } from "~/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@@ -20,15 +13,23 @@ import {
AlertDialogDescription, AlertDialogDescription,
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
} from "~/components/ui/alert-dialog"; } from "~/client/components/ui/alert-dialog";
import { VolumeIcon } from "~/components/volume-icon"; import { VolumeIcon } from "~/client/components/volume-icon";
import { parseError } from "~/lib/errors"; import { parseError } from "~/client/lib/errors";
import { cn } from "~/lib/utils"; import { cn } from "~/client/lib/utils";
import type { Route } from "./+types/volume-details"; import type { Route } from "./+types/volume-details";
import { getVolume } from "~/api-client";
import { VolumeInfoTabContent } from "../tabs/info"; import { VolumeInfoTabContent } from "../tabs/info";
import { FilesTabContent } from "../tabs/files"; import { FilesTabContent } from "../tabs/files";
import { DockerTabContent } from "../tabs/docker"; import { DockerTabContent } from "../tabs/docker";
import { Tooltip, TooltipContent, TooltipTrigger } from "~/client/components/ui/tooltip";
import { useSystemInfo } from "~/client/hooks/use-system-info";
import { getVolume } from "~/client/api-client";
import {
deleteVolumeMutation,
getVolumeOptions,
mountVolumeMutation,
unmountVolumeMutation,
} from "~/client/api-client/@tanstack/react-query.gen";
export function meta({ params }: Route.MetaArgs) { export function meta({ params }: Route.MetaArgs) {
return [ return [
@@ -41,7 +42,7 @@ export function meta({ params }: Route.MetaArgs) {
} }
export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => { export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => {
const volume = await getVolume({ path: { name: params.name ?? "" } }); const volume = await getVolume({ path: { name: params.name } });
if (volume.data) return volume.data; if (volume.data) return volume.data;
}; };
@@ -59,9 +60,7 @@ export default function VolumeDetails({ loaderData }: Route.ComponentProps) {
refetchOnWindowFocus: true, refetchOnWindowFocus: true,
}); });
const { data: systemInfo } = useQuery({ const { capabilities } = useSystemInfo();
...getSystemInfoOptions(),
});
const deleteVol = useMutation({ const deleteVol = useMutation({
...deleteVolumeMutation(), ...deleteVolumeMutation(),
@@ -114,7 +113,7 @@ export default function VolumeDetails({ loaderData }: Route.ComponentProps) {
} }
const { volume, statfs } = data; const { volume, statfs } = data;
const dockerAvailable = systemInfo?.capabilities?.docker ?? false; const dockerAvailable = capabilities.docker;
return ( return (
<> <>
@@ -150,7 +149,16 @@ export default function VolumeDetails({ loaderData }: Route.ComponentProps) {
<TabsList className="mb-2"> <TabsList className="mb-2">
<TabsTrigger value="info">Configuration</TabsTrigger> <TabsTrigger value="info">Configuration</TabsTrigger>
<TabsTrigger value="files">Files</TabsTrigger> <TabsTrigger value="files">Files</TabsTrigger>
{dockerAvailable && <TabsTrigger value="docker">Docker</TabsTrigger>} <Tooltip>
<TooltipTrigger>
<TabsTrigger disabled={!dockerAvailable} value="docker">
Docker
</TabsTrigger>
</TooltipTrigger>
<TooltipContent className={cn({ hidden: dockerAvailable })}>
<p>Enable Docker support to access this tab.</p>
</TooltipContent>
</Tooltip>
</TabsList> </TabsList>
<TabsContent value="info"> <TabsContent value="info">
<VolumeInfoTabContent volume={volume} statfs={statfs} /> <VolumeInfoTabContent volume={volume} statfs={statfs} />

View File

@@ -2,18 +2,18 @@ import { useQuery } from "@tanstack/react-query";
import { HardDrive, RotateCcw } from "lucide-react"; import { HardDrive, RotateCcw } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { listVolumes } from "~/api-client"; import { CreateVolumeDialog } from "~/client/components/create-volume-dialog";
import { listVolumesOptions } from "~/api-client/@tanstack/react-query.gen"; import { EmptyState } from "~/client/components/empty-state";
import { CreateVolumeDialog } from "~/components/create-volume-dialog"; import { StatusDot } from "~/client/components/status-dot";
import { EmptyState } from "~/components/empty-state"; import { Button } from "~/client/components/ui/button";
import { StatusDot } from "~/components/status-dot"; import { Card } from "~/client/components/ui/card";
import { Button } from "~/components/ui/button"; import { Input } from "~/client/components/ui/input";
import { Card } from "~/components/ui/card"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
import { Input } from "~/components/ui/input"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/client/components/ui/table";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select"; import { VolumeIcon } from "~/client/components/volume-icon";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/components/ui/table";
import { VolumeIcon } from "~/components/volume-icon";
import type { Route } from "./+types/volumes"; import type { Route } from "./+types/volumes";
import { listVolumes } from "~/client/api-client";
import { listVolumesOptions } from "~/client/api-client/@tanstack/react-query.gen";
export function meta(_: Route.MetaArgs) { export function meta(_: Route.MetaArgs) {
return [ return [
@@ -79,13 +79,13 @@ export default function Volumes({ loaderData }: Route.ComponentProps) {
<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"> <div className="flex flex-col lg:flex-row items-stretch lg:items-center gap-2 md:justify-between p-4 bg-card-header py-4">
<span className="flex flex-col sm:flex-row items-stretch md:items-center gap-0 flex-wrap "> <span className="flex flex-col sm:flex-row items-stretch md:items-center gap-0 flex-wrap ">
<Input <Input
className="w-full lg:w-[180px] min-w-[180px] mr-[-1px] mt-[-1px]" className="w-full lg:w-[180px] min-w-[180px] -mr-px -mt-px"
placeholder="Search volumes…" placeholder="Search volumes…"
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
/> />
<Select value={statusFilter} onValueChange={setStatusFilter}> <Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-full lg:w-[180px] min-w-[180px] mr-[-1px] mt-[-1px]"> <SelectTrigger className="w-full lg:w-[180px] min-w-[180px] -mr-px -mt-px">
<SelectValue placeholder="All status" /> <SelectValue placeholder="All status" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -95,7 +95,7 @@ export default function Volumes({ loaderData }: Route.ComponentProps) {
</SelectContent> </SelectContent>
</Select> </Select>
<Select value={backendFilter} onValueChange={setBackendFilter}> <Select value={backendFilter} onValueChange={setBackendFilter}>
<SelectTrigger className="w-full lg:w-[180px] min-w-[180px] mt-[-1px]"> <SelectTrigger className="w-full lg:w-[180px] min-w-[180px] -mt-px">
<SelectValue placeholder="All backends" /> <SelectValue placeholder="All backends" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>

View File

@@ -1,11 +1,11 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Unplug } from "lucide-react"; import { Unplug } from "lucide-react";
import * as YML from "yaml"; import * as YML from "yaml";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card"; import { getContainersUsingVolumeOptions } from "~/client/api-client/@tanstack/react-query.gen";
import { CodeBlock } from "~/components/ui/code-block"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/components/ui/table"; import { CodeBlock } from "~/client/components/ui/code-block";
import type { Volume } from "~/lib/types"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/client/components/ui/table";
import { getContainersUsingVolumeOptions } from "../../../api-client/@tanstack/react-query.gen"; import type { Volume } from "~/client/lib/types";
type Props = { type Props = {
volume: Volume; volume: Volume;
@@ -52,7 +52,7 @@ export const DockerTabContent = ({ volume }: Props) => {
}; };
return ( return (
<div className="grid gap-4 xl:grid-cols-[minmax(0,_1fr)_minmax(0,_1fr)]"> <div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Plug-and-play Docker integration</CardTitle> <CardTitle>Plug-and-play Docker integration</CardTitle>

View File

@@ -1,7 +1,7 @@
import { FolderOpen } from "lucide-react"; import { FolderOpen } from "lucide-react";
import { VolumeFileBrowser } from "~/components/volume-file-browser"; import { VolumeFileBrowser } from "~/client/components/volume-file-browser";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
import type { Volume } from "~/lib/types"; import type { Volume } from "~/client/lib/types";
type Props = { type Props = {
volume: Volume; volume: Volume;

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