Compare commits

..

33 Commits

Author SHA1 Message Date
Nicolas Meienberger
ed2a625fa7 ci: fix app version build arg 2025-11-16 17:11:30 +01:00
Nicolas Meienberger
a3e027694a ci: fix version injection to be a docker build arg 2025-11-16 16:53:29 +01:00
Copilot
0d36484c04 Add "Ironmount" prefix to page titles and display version in sidebar (#28)
* Initial plan

* Initial exploration - understanding the codebase

Co-authored-by: nicotsx <47644445+nicotsx@users.noreply.github.com>

* Add "Ironmount - " prefix to all route titles and version in sidebar

Co-authored-by: nicotsx <47644445+nicotsx@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: nicotsx <47644445+nicotsx@users.noreply.github.com>
2025-11-16 16:49:35 +01:00
Nicolas Meienberger
67b1accbd0 docs: add warning for location of /var/lib/ironmount 2025-11-16 12:53:13 +01:00
Nicolas Meienberger
98924ea59d fix: timezone parsing cron 2025-11-16 11:51:00 +01:00
Nico
e5435969be feat: remove individual snapshot (#26) 2025-11-16 11:14:18 +01:00
Nicolas Meienberger
c0bef7f65e chore: update versions in readme 2025-11-15 12:41:53 +01:00
Nicolas Meienberger
29c96c9fc6 ci: don't create gh release for alpha and beta versions 2025-11-15 12:28:34 +01:00
Nicolas Meienberger
2c0f22af59 fix(create-repo): don't try to load rclone remotes if the capability is disabled 2025-11-15 12:22:56 +01:00
Nicolas Meienberger
3ff6a04f8e feat(repositories): allow importing existing repos 2025-11-15 11:58:52 +01:00
Nicolas Meienberger
54ee02deb9 feat(backups): manual repository cleanup 2025-11-15 11:24:17 +01:00
Nicolas Meienberger
b83881c189 fix(backups): re-calculate next backup date before starting the backup 2025-11-15 11:13:23 +01:00
Nicolas Meienberger
d78b4adfd9 chore: update readme 2025-11-15 10:37:35 +01:00
Nicolas Meienberger
4d3ec524e2 chore: add all caps for dev container 2025-11-15 10:23:15 +01:00
Nicolas Meienberger
681cf5dff1 fix: hide test-connection button for directories 2025-11-15 10:15:25 +01:00
Nicolas Meienberger
31da747c2d fix: mount and unmount command not properly throwing errors 2025-11-15 10:08:16 +01:00
Nicolas Meienberger
b86081b2e8 Merge branch 'altendorfme-backup-file-path' 2025-11-15 09:51:05 +01:00
Nicolas Meienberger
3622fd57ef refactor(repository): keep the error if repo is already init 2025-11-15 09:45:04 +01:00
Nicolas Meienberger
5b1d7eff17 chore: update .gitignore 2025-11-15 09:45:04 +01:00
Nicolas Meienberger
2b3d8dffc5 Merge branch 'altendorfme-main' 2025-11-15 09:42:50 +01:00
Nicolas Meienberger
f517438a8e refactor(repository): keep the error if repo is already init 2025-11-15 09:42:29 +01:00
Nicolas Meienberger
1ddd4d701b chore: update .gitignore 2025-11-15 09:39:49 +01:00
Renan Bernordi
9a1797b8b2 backup file and folders 2025-11-14 23:21:13 -03:00
Renan Bernordi
52046c88cc support cloudflare r2 2025-11-14 22:37:27 -03:00
Nicolas Meienberger
951d9d970c chore: update readme 2025-11-14 22:44:58 +01:00
Nicolas Meienberger
ffc821af2b chore: update readme 2025-11-14 21:36:33 +01:00
Nicolas Meienberger
cfeff643c4 refactor(create-volume): from dialog to page 2025-11-14 21:23:52 +01:00
Nicolas Meienberger
c898e1ce07 refactor(create-repository): from dialog to page 2025-11-14 21:10:40 +01:00
Nicolas Meienberger
c179a16d15 refactor: small code style 2025-11-14 20:59:13 +01:00
Nicolas Meienberger
00916a1fd2 refactor(browsers): create hook for common operations 2025-11-14 20:56:06 +01:00
Nicolas Meienberger
18f863cbac chore: remove node_modules folder 2025-11-14 20:03:11 +01:00
Nicolas Meienberger
1b8595c17e fix: cookie not secure 2025-11-14 19:13:14 +01:00
Nicolas Meienberger
6e6becec3b refactor(breadcrumbs): use handler & match pattern 2025-11-13 22:28:53 +01:00
71 changed files with 5129 additions and 4584 deletions

View File

@@ -74,10 +74,13 @@ jobs:
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
APP_VERSION=${{ needs.determine-release-type.outputs.tagname }}
publish-release:
runs-on: ubuntu-latest
needs: [build-images]
needs: [build-images, determine-release-type]
if: needs.determine-release-type.outputs.release_type == 'release'
outputs:
id: ${{ steps.create_release.outputs.id }}
steps:

3
.gitignore vendored
View File

@@ -9,3 +9,6 @@
.env
.turbo
CLAUDE.md
mutagen.yml.lock
notes.md

View File

@@ -59,6 +59,8 @@ CMD ["bun", "run", "dev"]
# ------------------------------
FROM oven/bun:${BUN_VERSION} AS builder
ARG APP_VERSION=dev
WORKDIR /app
COPY ./package.json ./bun.lock ./
@@ -66,6 +68,9 @@ RUN bun install --frozen-lockfile
COPY . .
RUN touch .env
RUN echo "VITE_APP_VERSION=${APP_VERSION}" >> .env
RUN bun run build
FROM base AS production

View File

@@ -6,7 +6,7 @@
</a>
<br />
<figure>
<img src="https://github.com/nicotsx/ironmount/blob/main/screenshots/volume-details.png?raw=true" alt="Demo" />
<img src="https://github.com/nicotsx/ironmount/blob/main/screenshots/backup-details.png?raw=true" alt="Demo" />
<figcaption>
<p align="center">
Backup management with scheduling and monitoring
@@ -36,18 +36,23 @@ In order to run Ironmount, you need to have Docker and Docker Compose installed
```yaml
services:
ironmount:
image: ghcr.io/nicotsx/ironmount:v0.6
image: ghcr.io/nicotsx/ironmount:v0.10
container_name: ironmount
restart: unless-stopped
privileged: true
cap_add:
- SYS_ADMIN
ports:
- "4096:4096"
devices:
- /dev/fuse:/dev/fuse
volumes:
- /etc/localtime:/etc/localtime:ro
- /var/lib/ironmount:/var/lib/ironmount
```
> [!WARNING]
> Do not try to change the location of the bind mount `/var/lib/ironmount` on your host or store it on a network share. You will likely face permission issues and strong performance degradation.
Then, run the following command to start Ironmount:
```bash
@@ -67,7 +72,7 @@ If you want to track a local directory on the same server where Ironmount is run
```diff
services:
ironmount:
image: ghcr.io/nicotsx/ironmount:v0.6
image: ghcr.io/nicotsx/ironmount:v0.10
container_name: ironmount
restart: unless-stopped
cap_add:
@@ -77,6 +82,7 @@ services:
devices:
- /dev/fuse:/dev/fuse
volumes:
- /etc/localtime:/etc/localtime:ro
- /var/lib/ironmount:/var/lib/ironmount
+ - /path/to/your/directory:/mydata
```
@@ -132,15 +138,17 @@ Ironmount can use [rclone](https://rclone.org/) to support 40+ cloud storage pro
```diff
services:
ironmount:
image: ghcr.io/nicotsx/ironmount:v0.6
image: ghcr.io/nicotsx/ironmount:v0.10
container_name: ironmount
restart: unless-stopped
privileged: true
cap_add:
- SYS_ADMIN
ports:
- "4096:4096"
devices:
- /dev/fuse:/dev/fuse
volumes:
- /etc/localtime:/etc/localtime:ro
- /var/lib/ironmount:/var/lib/ironmount
+ - ~/.config/rclone:/root/.config/rclone
```
@@ -187,7 +195,7 @@ In order to enable this feature, you need to change your bind mount `/var/lib/ir
```diff
services:
ironmount:
image: ghcr.io/nicotsx/ironmount:v0.6
image: ghcr.io/nicotsx/ironmount:v0.10
container_name: ironmount
restart: unless-stopped
ports:
@@ -195,6 +203,7 @@ services:
devices:
- /dev/fuse:/dev/fuse
volumes:
- /etc/localtime:/etc/localtime:ro
- - /var/lib/ironmount:/var/lib/ironmount
+ - /var/lib/ironmount:/var/lib/ironmount:rshared
```
@@ -215,7 +224,7 @@ In order to enable this feature, you need to run Ironmount with several items sh
```diff
services:
ironmount:
image: ghcr.io/nicotsx/ironmount:v0.6
image: ghcr.io/nicotsx/ironmount:v0.10
container_name: ironmount
restart: unless-stopped
cap_add:
@@ -225,6 +234,7 @@ services:
devices:
- /dev/fuse:/dev/fuse
volumes:
- /etc/localtime:/etc/localtime:ro
- - /var/lib/ironmount:/var/lib/ironmount
+ - /var/lib/ironmount:/var/lib/ironmount:rshared
+ - /run/docker/plugins:/run/docker/plugins

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,9 +1,14 @@
// This file is auto-generated by @hey-api/openapi-ts
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 { 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 {
buildUrl,
createConfig,
@@ -12,9 +17,9 @@ import {
mergeConfigs,
mergeHeaders,
setAuthParams,
} from "./utils.gen";
} from './utils.gen';
type ReqInit = Omit<RequestInit, "body" | "headers"> & {
type ReqInit = Omit<RequestInit, 'body' | 'headers'> & {
body?: any;
headers: ReturnType<typeof mergeHeaders>;
};
@@ -29,7 +34,12 @@ export const createClient = (config: Config = {}): Client => {
return getConfig();
};
const interceptors = createInterceptors<Request, Response, unknown, ResolvedRequestOptions>();
const interceptors = createInterceptors<
Request,
Response,
unknown,
ResolvedRequestOptions
>();
const beforeRequest = async (options: RequestOptions) => {
const opts = {
@@ -56,8 +66,8 @@ export const createClient = (config: Config = {}): Client => {
}
// remove Content-Type header if body is empty to avoid sending invalid requests
if (opts.body === undefined || opts.serializedBody === "") {
opts.headers.delete("Content-Type");
if (opts.body === undefined || opts.serializedBody === '') {
opts.headers.delete('Content-Type');
}
const url = buildUrl(opts);
@@ -65,11 +75,11 @@ export const createClient = (config: Config = {}): Client => {
return { opts, url };
};
const request: Client["request"] = async (options) => {
const request: Client['request'] = async (options) => {
// @ts-expect-error
const { opts, url } = await beforeRequest(options);
const requestInit: ReqInit = {
redirect: "follow",
redirect: 'follow',
...opts,
body: getValidRequestBody(opts),
};
@@ -95,7 +105,12 @@ export const createClient = (config: Config = {}): Client => {
for (const fn of interceptors.error.fns) {
if (fn) {
finalError = (await fn(error, undefined as any, request, opts)) as unknown;
finalError = (await fn(
error,
undefined as any,
request,
opts,
)) as unknown;
}
}
@@ -106,7 +121,7 @@ export const createClient = (config: Config = {}): Client => {
}
// Return error response
return opts.responseStyle === "data"
return opts.responseStyle === 'data'
? undefined
: {
error: finalError,
@@ -128,28 +143,33 @@ export const createClient = (config: Config = {}): Client => {
if (response.ok) {
const parseAs =
(opts.parseAs === "auto" ? getParseAs(response.headers.get("Content-Type")) : opts.parseAs) ?? "json";
(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":
case 'arrayBuffer':
case 'blob':
case 'text':
emptyData = await response[parseAs]();
break;
case "formData":
case 'formData':
emptyData = new FormData();
break;
case "stream":
case 'stream':
emptyData = response.body;
break;
case "json":
case 'json':
default:
emptyData = {};
break;
}
return opts.responseStyle === "data"
return opts.responseStyle === 'data'
? emptyData
: {
data: emptyData,
@@ -159,15 +179,15 @@ export const createClient = (config: Config = {}): Client => {
let data: any;
switch (parseAs) {
case "arrayBuffer":
case "blob":
case "formData":
case "json":
case "text":
case 'arrayBuffer':
case 'blob':
case 'formData':
case 'json':
case 'text':
data = await response[parseAs]();
break;
case "stream":
return opts.responseStyle === "data"
case 'stream':
return opts.responseStyle === 'data'
? response.body
: {
data: response.body,
@@ -175,7 +195,7 @@ export const createClient = (config: Config = {}): Client => {
};
}
if (parseAs === "json") {
if (parseAs === 'json') {
if (opts.responseValidator) {
await opts.responseValidator(data);
}
@@ -185,7 +205,7 @@ export const createClient = (config: Config = {}): Client => {
}
}
return opts.responseStyle === "data"
return opts.responseStyle === 'data'
? data
: {
data,
@@ -218,7 +238,7 @@ export const createClient = (config: Config = {}): Client => {
}
// TODO: we probably want to return error and improve types
return opts.responseStyle === "data"
return opts.responseStyle === 'data'
? undefined
: {
error: finalError,
@@ -226,9 +246,12 @@ export const createClient = (config: Config = {}): Client => {
};
};
const makeMethodFn = (method: Uppercase<HttpMethod>) => (options: RequestOptions) => request({ ...options, method });
const makeMethodFn =
(method: Uppercase<HttpMethod>) => (options: RequestOptions) =>
request({ ...options, method });
const makeSseFn = (method: Uppercase<HttpMethod>) => async (options: RequestOptions) => {
const makeSseFn =
(method: Uppercase<HttpMethod>) => async (options: RequestOptions) => {
const { opts, url } = await beforeRequest(options);
return createSseClient({
...opts,
@@ -250,29 +273,29 @@ export const createClient = (config: Config = {}): Client => {
return {
buildUrl,
connect: makeMethodFn("CONNECT"),
delete: makeMethodFn("DELETE"),
get: makeMethodFn("GET"),
connect: makeMethodFn('CONNECT'),
delete: makeMethodFn('DELETE'),
get: makeMethodFn('GET'),
getConfig,
head: makeMethodFn("HEAD"),
head: makeMethodFn('HEAD'),
interceptors,
options: makeMethodFn("OPTIONS"),
patch: makeMethodFn("PATCH"),
post: makeMethodFn("POST"),
put: makeMethodFn("PUT"),
options: makeMethodFn('OPTIONS'),
patch: makeMethodFn('PATCH'),
post: makeMethodFn('POST'),
put: makeMethodFn('PUT'),
request,
setConfig,
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"),
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"),
trace: makeMethodFn('TRACE'),
} as Client;
};

View File

@@ -1,15 +1,15 @@
// This file is auto-generated by @hey-api/openapi-ts
export type { Auth } from "../core/auth.gen";
export type { QuerySerializerOptions } from "../core/bodySerializer.gen";
export type { Auth } from '../core/auth.gen';
export type { QuerySerializerOptions } from '../core/bodySerializer.gen';
export {
formDataBodySerializer,
jsonBodySerializer,
urlSearchParamsBodySerializer,
} from "../core/bodySerializer.gen";
export { buildClientParams } from "../core/params.gen";
export { serializeQueryKeyValue } from "../core/queryKeySerializer.gen";
export { createClient } from "./client.gen";
} from '../core/bodySerializer.gen';
export { buildClientParams } from '../core/params.gen';
export { serializeQueryKeyValue } from '../core/queryKeySerializer.gen';
export { createClient } from './client.gen';
export type {
Client,
ClientOptions,
@@ -21,5 +21,5 @@ export type {
ResolvedRequestOptions,
ResponseStyle,
TDataShape,
} from "./types.gen";
export { createConfig, mergeHeaders } from "./utils.gen";
} from './types.gen';
export { createConfig, mergeHeaders } from './utils.gen';

View File

@@ -1,19 +1,25 @@
// This file is auto-generated by @hey-api/openapi-ts
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 { Middleware } from "./utils.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 { Middleware } from './utils.gen';
export type ResponseStyle = "data" | "fields";
export type ResponseStyle = 'data' | 'fields';
export interface Config<T extends ClientOptions = ClientOptions>
extends Omit<RequestInit, "body" | "headers" | "method">,
extends Omit<RequestInit, 'body' | 'headers' | 'method'>,
CoreConfig {
/**
* Base URL for all requests made by this client.
*/
baseUrl?: T["baseUrl"];
baseUrl?: T['baseUrl'];
/**
* Fetch API implementation. You can use this option to provide a custom
* fetch instance.
@@ -36,7 +42,14 @@ export interface Config<T extends ClientOptions = ClientOptions>
*
* @default 'auto'
*/
parseAs?: "arrayBuffer" | "auto" | "blob" | "formData" | "json" | "stream" | "text";
parseAs?:
| 'arrayBuffer'
| 'auto'
| 'blob'
| 'formData'
| 'json'
| 'stream'
| 'text';
/**
* Should we return only data or multiple fields (data, error, response, etc.)?
*
@@ -48,12 +61,12 @@ export interface Config<T extends ClientOptions = ClientOptions>
*
* @default false
*/
throwOnError?: T["throwOnError"];
throwOnError?: T['throwOnError'];
}
export interface RequestOptions<
TData = unknown,
TResponseStyle extends ResponseStyle = "fields",
TResponseStyle extends ResponseStyle = 'fields',
ThrowOnError extends boolean = boolean,
Url extends string = string,
> extends Config<{
@@ -62,7 +75,11 @@ export interface RequestOptions<
}>,
Pick<
ServerSentEventsOptions<TData>,
"onSseError" | "onSseEvent" | "sseDefaultRetryDelay" | "sseMaxRetryAttempts" | "sseMaxRetryDelay"
| 'onSseError'
| 'onSseEvent'
| 'sseDefaultRetryDelay'
| 'sseMaxRetryAttempts'
| 'sseMaxRetryDelay'
> {
/**
* Any body that you want to add to your request.
@@ -80,7 +97,7 @@ export interface RequestOptions<
}
export interface ResolvedRequestOptions<
TResponseStyle extends ResponseStyle = "fields",
TResponseStyle extends ResponseStyle = 'fields',
ThrowOnError extends boolean = boolean,
Url extends string = string,
> extends RequestOptions<unknown, TResponseStyle, ThrowOnError, Url> {
@@ -91,30 +108,40 @@ export type RequestResult<
TData = unknown,
TError = unknown,
ThrowOnError extends boolean = boolean,
TResponseStyle extends ResponseStyle = "fields",
TResponseStyle extends ResponseStyle = 'fields',
> = ThrowOnError extends true
? Promise<
TResponseStyle extends "data"
TResponseStyle extends 'data'
? TData extends Record<string, unknown>
? TData[keyof TData]
: TData
: {
data: TData extends Record<string, unknown> ? TData[keyof TData] : TData;
data: TData extends Record<string, unknown>
? TData[keyof TData]
: TData;
request: Request;
response: Response;
}
>
: Promise<
TResponseStyle extends "data"
? (TData extends Record<string, unknown> ? TData[keyof TData] : TData) | undefined
TResponseStyle extends 'data'
?
| (TData extends Record<string, unknown>
? TData[keyof TData]
: TData)
| undefined
: (
| {
data: TData extends Record<string, unknown> ? TData[keyof TData] : TData;
data: TData extends Record<string, unknown>
? TData[keyof TData]
: TData;
error: undefined;
}
| {
data: undefined;
error: TError extends Record<string, unknown> ? TError[keyof TError] : TError;
error: TError extends Record<string, unknown>
? TError[keyof TError]
: TError;
}
) & {
request: Request;
@@ -132,28 +159,31 @@ type MethodFn = <
TData = unknown,
TError = unknown,
ThrowOnError extends boolean = false,
TResponseStyle extends ResponseStyle = "fields",
TResponseStyle extends ResponseStyle = 'fields',
>(
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, "method">,
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, 'method'>,
) => RequestResult<TData, TError, ThrowOnError, TResponseStyle>;
type SseFn = <
TData = unknown,
TError = unknown,
ThrowOnError extends boolean = false,
TResponseStyle extends ResponseStyle = "fields",
TResponseStyle extends ResponseStyle = 'fields',
>(
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, "method">,
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, 'method'>,
) => Promise<ServerSentEventsResult<TData, TError>>;
type RequestFn = <
TData = unknown,
TError = unknown,
ThrowOnError extends boolean = false,
TResponseStyle extends ResponseStyle = "fields",
TResponseStyle extends ResponseStyle = 'fields',
>(
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, "method"> &
Pick<Required<RequestOptions<TData, TResponseStyle, ThrowOnError>>, "method">,
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, 'method'> &
Pick<
Required<RequestOptions<TData, TResponseStyle, ThrowOnError>>,
'method'
>,
) => RequestResult<TData, TError, ThrowOnError, TResponseStyle>;
type BuildUrlFn = <
@@ -167,7 +197,13 @@ type BuildUrlFn = <
options: TData & Options<TData>,
) => string;
export type Client = CoreClient<RequestFn, Config, MethodFn, BuildUrlFn, SseFn> & {
export type Client = CoreClient<
RequestFn,
Config,
MethodFn,
BuildUrlFn,
SseFn
> & {
interceptors: Middleware<Request, Response, unknown, ResolvedRequestOptions>;
};
@@ -197,6 +233,9 @@ export type Options<
TData extends TDataShape = TDataShape,
ThrowOnError extends boolean = boolean,
TResponse = unknown,
TResponseStyle extends ResponseStyle = "fields",
> = OmitKeys<RequestOptions<TResponse, TResponseStyle, ThrowOnError>, "body" | "path" | "query" | "url"> &
([TData] extends [never] ? unknown : Omit<TData, "url">);
TResponseStyle extends ResponseStyle = 'fields',
> = OmitKeys<
RequestOptions<TResponse, TResponseStyle, ThrowOnError>,
'body' | 'path' | 'query' | 'url'
> &
([TData] extends [never] ? unknown : Omit<TData, 'url'>);

View File

@@ -1,16 +1,23 @@
// This file is auto-generated by @hey-api/openapi-ts
import { getAuthToken } from "../core/auth.gen";
import type { QuerySerializerOptions } from "../core/bodySerializer.gen";
import { jsonBodySerializer } from "../core/bodySerializer.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 { getAuthToken } from '../core/auth.gen';
import type { QuerySerializerOptions } from '../core/bodySerializer.gen';
import { jsonBodySerializer } from '../core/bodySerializer.gen';
import {
serializeArrayParam,
serializeObjectParam,
serializePrimitiveParam,
} from '../core/pathSerializer.gen';
import { getUrl } from '../core/utils.gen';
import type { Client, ClientOptions, Config, RequestOptions } from './types.gen';
export const createQuerySerializer = <T = unknown>({ parameters = {}, ...args }: QuerySerializerOptions = {}) => {
export const createQuerySerializer = <T = unknown>({
parameters = {},
...args
}: QuerySerializerOptions = {}) => {
const querySerializer = (queryParams: T) => {
const search: string[] = [];
if (queryParams && typeof queryParams === "object") {
if (queryParams && typeof queryParams === 'object') {
for (const name in queryParams) {
const value = queryParams[name];
@@ -25,17 +32,17 @@ export const createQuerySerializer = <T = unknown>({ parameters = {}, ...args }:
allowReserved: options.allowReserved,
explode: true,
name,
style: "form",
style: 'form',
value,
...options.array,
});
if (serializedArray) search.push(serializedArray);
} else if (typeof value === "object") {
} else if (typeof value === 'object') {
const serializedObject = serializeObjectParam({
allowReserved: options.allowReserved,
explode: true,
name,
style: "deepObject",
style: 'deepObject',
value: value as Record<string, unknown>,
...options.object,
});
@@ -50,7 +57,7 @@ export const createQuerySerializer = <T = unknown>({ parameters = {}, ...args }:
}
}
}
return search.join("&");
return search.join('&');
};
return querySerializer;
};
@@ -58,40 +65,49 @@ export const createQuerySerializer = <T = unknown>({ parameters = {}, ...args }:
/**
* Infers parseAs value from provided Content-Type header.
*/
export const getParseAs = (contentType: string | null): Exclude<Config["parseAs"], "auto"> => {
export const getParseAs = (
contentType: string | null,
): Exclude<Config['parseAs'], 'auto'> => {
if (!contentType) {
// If no Content-Type header is provided, the best we can do is return the raw response body,
// which is effectively the same as the 'stream' option.
return "stream";
return 'stream';
}
const cleanContent = contentType.split(";")[0]?.trim();
const cleanContent = contentType.split(';')[0]?.trim();
if (!cleanContent) {
return;
}
if (cleanContent.startsWith("application/json") || cleanContent.endsWith("+json")) {
return "json";
if (
cleanContent.startsWith('application/json') ||
cleanContent.endsWith('+json')
) {
return 'json';
}
if (cleanContent === "multipart/form-data") {
return "formData";
if (cleanContent === 'multipart/form-data') {
return 'formData';
}
if (["application/", "audio/", "image/", "video/"].some((type) => cleanContent.startsWith(type))) {
return "blob";
if (
['application/', 'audio/', 'image/', 'video/'].some((type) =>
cleanContent.startsWith(type),
)
) {
return 'blob';
}
if (cleanContent.startsWith("text/")) {
return "text";
if (cleanContent.startsWith('text/')) {
return 'text';
}
return;
};
const checkForExistence = (
options: Pick<RequestOptions, "auth" | "query"> & {
options: Pick<RequestOptions, 'auth' | 'query'> & {
headers: Headers;
},
name?: string,
@@ -99,7 +115,11 @@ const checkForExistence = (
if (!name) {
return false;
}
if (options.headers.has(name) || options.query?.[name] || options.headers.get("Cookie")?.includes(`${name}=`)) {
if (
options.headers.has(name) ||
options.query?.[name] ||
options.headers.get('Cookie')?.includes(`${name}=`)
) {
return true;
}
return false;
@@ -108,8 +128,8 @@ const checkForExistence = (
export const setAuthParams = async ({
security,
...options
}: Pick<Required<RequestOptions>, "security"> &
Pick<RequestOptions, "auth" | "query"> & {
}: Pick<Required<RequestOptions>, 'security'> &
Pick<RequestOptions, 'auth' | 'query'> & {
headers: Headers;
}) => {
for (const auth of security) {
@@ -123,19 +143,19 @@ export const setAuthParams = async ({
continue;
}
const name = auth.name ?? "Authorization";
const name = auth.name ?? 'Authorization';
switch (auth.in) {
case "query":
case 'query':
if (!options.query) {
options.query = {};
}
options.query[name] = token;
break;
case "cookie":
options.headers.append("Cookie", `${name}=${token}`);
case 'cookie':
options.headers.append('Cookie', `${name}=${token}`);
break;
case "header":
case 'header':
default:
options.headers.set(name, token);
break;
@@ -143,13 +163,13 @@ export const setAuthParams = async ({
}
};
export const buildUrl: Client["buildUrl"] = (options) =>
export const buildUrl: Client['buildUrl'] = (options) =>
getUrl({
baseUrl: options.baseUrl as string,
path: options.path,
query: options.query,
querySerializer:
typeof options.querySerializer === "function"
typeof options.querySerializer === 'function'
? options.querySerializer
: createQuerySerializer(options.querySerializer),
url: options.url,
@@ -157,7 +177,7 @@ export const buildUrl: Client["buildUrl"] = (options) =>
export const mergeConfigs = (a: Config, b: Config): Config => {
const config = { ...a, ...b };
if (config.baseUrl?.endsWith("/")) {
if (config.baseUrl?.endsWith('/')) {
config.baseUrl = config.baseUrl.substring(0, config.baseUrl.length - 1);
}
config.headers = mergeHeaders(a.headers, b.headers);
@@ -172,14 +192,19 @@ const headersEntries = (headers: Headers): Array<[string, string]> => {
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();
for (const header of headers) {
if (!header) {
continue;
}
const iterator = header instanceof Headers ? headersEntries(header) : Object.entries(header);
const iterator =
header instanceof Headers
? headersEntries(header)
: Object.entries(header);
for (const [key, value] of iterator) {
if (value === null) {
@@ -191,7 +216,10 @@ export const mergeHeaders = (...headers: Array<Required<Config>["headers"] | und
} else if (value !== undefined) {
// assume object headers are meant to be JSON stringified, i.e. their
// content value in OpenAPI specification is 'application/json'
mergedHeaders.set(key, typeof value === "object" ? JSON.stringify(value) : (value as string));
mergedHeaders.set(
key,
typeof value === 'object' ? JSON.stringify(value) : (value as string),
);
}
}
}
@@ -205,9 +233,16 @@ type ErrInterceptor<Err, Res, Req, Options> = (
options: Options,
) => Err | Promise<Err>;
type ReqInterceptor<Req, Options> = (request: Req, options: Options) => Req | Promise<Req>;
type ReqInterceptor<Req, Options> = (
request: Req,
options: Options,
) => Req | Promise<Req>;
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> {
fns: Array<Interceptor | null> = [];
@@ -229,13 +264,16 @@ class Interceptors<Interceptor> {
}
getInterceptorIndex(id: number | Interceptor): number {
if (typeof id === "number") {
if (typeof id === 'number') {
return this.fns[id] ? id : -1;
}
return this.fns.indexOf(id);
}
update(id: number | Interceptor, fn: Interceptor): number | Interceptor | false {
update(
id: number | Interceptor,
fn: Interceptor,
): number | Interceptor | false {
const index = this.getInterceptorIndex(id);
if (this.fns[index]) {
this.fns[index] = fn;
@@ -256,7 +294,12 @@ export interface Middleware<Req, Res, Err, Options> {
response: Interceptors<ResInterceptor<Res, Req, Options>>;
}
export const createInterceptors = <Req, Res, Err, Options>(): Middleware<Req, Res, Err, Options> => ({
export const createInterceptors = <Req, Res, Err, Options>(): Middleware<
Req,
Res,
Err,
Options
> => ({
error: new Interceptors<ErrInterceptor<Err, Res, Req, Options>>(),
request: new Interceptors<ReqInterceptor<Req, Options>>(),
response: new Interceptors<ResInterceptor<Res, Req, Options>>(),
@@ -266,16 +309,16 @@ const defaultQuerySerializer = createQuerySerializer({
allowReserved: false,
array: {
explode: true,
style: "form",
style: 'form',
},
object: {
explode: true,
style: "deepObject",
style: 'deepObject',
},
});
const defaultHeaders = {
"Content-Type": "application/json",
'Content-Type': 'application/json',
};
export const createConfig = <T extends ClientOptions = ClientOptions>(
@@ -283,7 +326,7 @@ export const createConfig = <T extends ClientOptions = ClientOptions>(
): Config<Omit<ClientOptions, keyof T> & T> => ({
...jsonBodySerializer,
headers: defaultHeaders,
parseAs: "auto",
parseAs: 'auto',
querySerializer: defaultQuerySerializer,
...override,
});

View File

@@ -8,32 +8,33 @@ export interface Auth {
*
* @default 'header'
*/
in?: "header" | "query" | "cookie";
in?: 'header' | 'query' | 'cookie';
/**
* Header or query parameter name.
*
* @default 'Authorization'
*/
name?: string;
scheme?: "basic" | "bearer";
type: "apiKey" | "http";
scheme?: 'basic' | 'bearer';
type: 'apiKey' | 'http';
}
export const getAuthToken = async (
auth: Auth,
callback: ((auth: Auth) => Promise<AuthToken> | AuthToken) | AuthToken,
): Promise<string | undefined> => {
const token = typeof callback === "function" ? await callback(auth) : callback;
const token =
typeof callback === 'function' ? await callback(auth) : callback;
if (!token) {
return;
}
if (auth.scheme === "bearer") {
if (auth.scheme === 'bearer') {
return `Bearer ${token}`;
}
if (auth.scheme === "basic") {
if (auth.scheme === 'basic') {
return `Basic ${btoa(token)}`;
}

View File

@@ -1,6 +1,10 @@
// This file is auto-generated by @hey-api/openapi-ts
import type { ArrayStyle, ObjectStyle, SerializerOptions } from "./pathSerializer.gen";
import type {
ArrayStyle,
ObjectStyle,
SerializerOptions,
} from './pathSerializer.gen';
export type QuerySerializer = (query: Record<string, unknown>) => string;
@@ -20,8 +24,12 @@ export type QuerySerializerOptions = QuerySerializerOptionsObject & {
parameters?: Record<string, QuerySerializerOptionsObject>;
};
const serializeFormDataPair = (data: FormData, key: string, value: unknown): void => {
if (typeof value === "string" || value instanceof Blob) {
const serializeFormDataPair = (
data: FormData,
key: string,
value: unknown,
): void => {
if (typeof value === 'string' || value instanceof Blob) {
data.append(key, value);
} else if (value instanceof Date) {
data.append(key, value.toISOString());
@@ -30,8 +38,12 @@ const serializeFormDataPair = (data: FormData, key: string, value: unknown): voi
}
};
const serializeUrlSearchParamsPair = (data: URLSearchParams, key: string, value: unknown): void => {
if (typeof value === "string") {
const serializeUrlSearchParamsPair = (
data: URLSearchParams,
key: string,
value: unknown,
): void => {
if (typeof value === 'string') {
data.append(key, value);
} else {
data.append(key, JSON.stringify(value));
@@ -39,7 +51,9 @@ const serializeUrlSearchParamsPair = (data: URLSearchParams, key: string, value:
};
export const formDataBodySerializer = {
bodySerializer: <T extends Record<string, any> | Array<Record<string, any>>>(body: T): FormData => {
bodySerializer: <T extends Record<string, any> | Array<Record<string, any>>>(
body: T,
): FormData => {
const data = new FormData();
Object.entries(body).forEach(([key, value]) => {
@@ -59,11 +73,15 @@ export const formDataBodySerializer = {
export const jsonBodySerializer = {
bodySerializer: <T>(body: T): string =>
JSON.stringify(body, (_key, value) => (typeof value === "bigint" ? value.toString() : value)),
JSON.stringify(body, (_key, value) =>
typeof value === 'bigint' ? value.toString() : value,
),
};
export const urlSearchParamsBodySerializer = {
bodySerializer: <T extends Record<string, any> | Array<Record<string, any>>>(body: T): string => {
bodySerializer: <T extends Record<string, any> | Array<Record<string, any>>>(
body: T,
): string => {
const data = new URLSearchParams();
Object.entries(body).forEach(([key, value]) => {

View File

@@ -1,10 +1,10 @@
// This file is auto-generated by @hey-api/openapi-ts
type Slot = "body" | "headers" | "path" | "query";
type Slot = 'body' | 'headers' | 'path' | 'query';
export type Field =
| {
in: Exclude<Slot, "body">;
in: Exclude<Slot, 'body'>;
/**
* Field name. This is the name we want the user to see and use.
*/
@@ -16,7 +16,7 @@ export type Field =
map?: string;
}
| {
in: Extract<Slot, "body">;
in: Extract<Slot, 'body'>;
/**
* Key isn't required for bodies.
*/
@@ -43,10 +43,10 @@ export interface Fields {
export type FieldsConfig = ReadonlyArray<Field | Fields>;
const extraPrefixesMap: Record<string, Slot> = {
$body_: "body",
$headers_: "headers",
$path_: "path",
$query_: "query",
$body_: 'body',
$headers_: 'headers',
$path_: 'path',
$query_: 'query',
};
const extraPrefixes = Object.entries(extraPrefixesMap);
@@ -68,14 +68,14 @@ const buildKeyMap = (fields: FieldsConfig, map?: KeyMap): KeyMap => {
}
for (const config of fields) {
if ("in" in config) {
if ('in' in config) {
if (config.key) {
map.set(config.key, {
in: config.in,
map: config.map,
});
}
} else if ("key" in config) {
} else if ('key' in config) {
map.set(config.key, {
map: config.map,
});
@@ -96,13 +96,16 @@ interface Params {
const stripEmptySlots = (params: Params) => {
for (const [slot, value] of Object.entries(params)) {
if (value && typeof value === "object" && !Object.keys(value).length) {
if (value && typeof value === 'object' && !Object.keys(value).length) {
delete params[slot as Slot];
}
}
};
export const buildClientParams = (args: ReadonlyArray<unknown>, fields: FieldsConfig) => {
export const buildClientParams = (
args: ReadonlyArray<unknown>,
fields: FieldsConfig,
) => {
const params: Params = {
body: {},
headers: {},
@@ -123,7 +126,7 @@ export const buildClientParams = (args: ReadonlyArray<unknown>, fields: FieldsCo
continue;
}
if ("in" in config) {
if ('in' in config) {
if (config.key) {
const field = map.get(config.key)!;
const name = field.map || config.key;
@@ -145,12 +148,16 @@ export const buildClientParams = (args: ReadonlyArray<unknown>, fields: FieldsCo
params[field.map] = value;
}
} else {
const extra = extraPrefixes.find(([prefix]) => key.startsWith(prefix));
const extra = extraPrefixes.find(([prefix]) =>
key.startsWith(prefix),
);
if (extra) {
const [prefix, slot] = extra;
(params[slot] as Record<string, unknown>)[key.slice(prefix.length)] = value;
} else if ("allowExtra" in config && config.allowExtra) {
(params[slot] as Record<string, unknown>)[
key.slice(prefix.length)
] = value;
} else if ('allowExtra' in config && config.allowExtra) {
for (const [slot, allowed] of Object.entries(config.allowExtra)) {
if (allowed) {
(params[slot as Slot] as Record<string, unknown>)[key] = value;

View File

@@ -1,6 +1,8 @@
// This file is auto-generated by @hey-api/openapi-ts
interface SerializeOptions<T> extends SerializePrimitiveOptions, SerializerOptions<T> {}
interface SerializeOptions<T>
extends SerializePrimitiveOptions,
SerializerOptions<T> {}
interface SerializePrimitiveOptions {
allowReserved?: boolean;
@@ -15,10 +17,10 @@ export interface SerializerOptions<T> {
style: T;
}
export type ArrayStyle = "form" | "spaceDelimited" | "pipeDelimited";
export type ArrayStyle = 'form' | 'spaceDelimited' | 'pipeDelimited';
export type ArraySeparatorStyle = ArrayStyle | MatrixStyle;
type MatrixStyle = "label" | "matrix" | "simple";
export type ObjectStyle = "form" | "deepObject";
type MatrixStyle = 'label' | 'matrix' | 'simple';
export type ObjectStyle = 'form' | 'deepObject';
type ObjectSeparatorStyle = ObjectStyle | MatrixStyle;
interface SerializePrimitiveParam extends SerializePrimitiveOptions {
@@ -27,40 +29,40 @@ interface SerializePrimitiveParam extends SerializePrimitiveOptions {
export const separatorArrayExplode = (style: ArraySeparatorStyle) => {
switch (style) {
case "label":
return ".";
case "matrix":
return ";";
case "simple":
return ",";
case 'label':
return '.';
case 'matrix':
return ';';
case 'simple':
return ',';
default:
return "&";
return '&';
}
};
export const separatorArrayNoExplode = (style: ArraySeparatorStyle) => {
switch (style) {
case "form":
return ",";
case "pipeDelimited":
return "|";
case "spaceDelimited":
return "%20";
case 'form':
return ',';
case 'pipeDelimited':
return '|';
case 'spaceDelimited':
return '%20';
default:
return ",";
return ',';
}
};
export const separatorObjectExplode = (style: ObjectSeparatorStyle) => {
switch (style) {
case "label":
return ".";
case "matrix":
return ";";
case "simple":
return ",";
case 'label':
return '.';
case 'matrix':
return ';';
case 'simple':
return ',';
default:
return "&";
return '&';
}
};
@@ -74,15 +76,15 @@ export const serializeArrayParam = ({
value: unknown[];
}) => {
if (!explode) {
const joinedValues = (allowReserved ? value : value.map((v) => encodeURIComponent(v as string))).join(
separatorArrayNoExplode(style),
);
const joinedValues = (
allowReserved ? value : value.map((v) => encodeURIComponent(v as string))
).join(separatorArrayNoExplode(style));
switch (style) {
case "label":
case 'label':
return `.${joinedValues}`;
case "matrix":
case 'matrix':
return `;${name}=${joinedValues}`;
case "simple":
case 'simple':
return joinedValues;
default:
return `${name}=${joinedValues}`;
@@ -92,7 +94,7 @@ export const serializeArrayParam = ({
const separator = separatorArrayExplode(style);
const joinedValues = value
.map((v) => {
if (style === "label" || style === "simple") {
if (style === 'label' || style === 'simple') {
return allowReserved ? v : encodeURIComponent(v as string);
}
@@ -103,17 +105,23 @@ export const serializeArrayParam = ({
});
})
.join(separator);
return style === "label" || style === "matrix" ? separator + joinedValues : joinedValues;
return style === 'label' || style === 'matrix'
? separator + joinedValues
: joinedValues;
};
export const serializePrimitiveParam = ({ allowReserved, name, value }: SerializePrimitiveParam) => {
export const serializePrimitiveParam = ({
allowReserved,
name,
value,
}: SerializePrimitiveParam) => {
if (value === undefined || value === null) {
return "";
return '';
}
if (typeof value === "object") {
if (typeof value === 'object') {
throw new Error(
"Deeply-nested arrays/objects arent supported. Provide your own `querySerializer()` to handle these.",
'Deeply-nested arrays/objects arent supported. Provide your own `querySerializer()` to handle these.',
);
}
@@ -135,18 +143,22 @@ export const serializeObjectParam = ({
return valueOnly ? value.toISOString() : `${name}=${value.toISOString()}`;
}
if (style !== "deepObject" && !explode) {
if (style !== 'deepObject' && !explode) {
let values: string[] = [];
Object.entries(value).forEach(([key, v]) => {
values = [...values, key, allowReserved ? (v as string) : encodeURIComponent(v as string)];
values = [
...values,
key,
allowReserved ? (v as string) : encodeURIComponent(v as string),
];
});
const joinedValues = values.join(",");
const joinedValues = values.join(',');
switch (style) {
case "form":
case 'form':
return `${name}=${joinedValues}`;
case "label":
case 'label':
return `.${joinedValues}`;
case "matrix":
case 'matrix':
return `;${name}=${joinedValues}`;
default:
return joinedValues;
@@ -158,10 +170,12 @@ export const serializeObjectParam = ({
.map(([key, v]) =>
serializePrimitiveParam({
allowReserved,
name: style === "deepObject" ? `${name}[${key}]` : key,
name: style === 'deepObject' ? `${name}[${key}]` : key,
value: v as string,
}),
)
.join(separator);
return style === "label" || style === "matrix" ? separator + joinedValues : joinedValues;
return style === 'label' || style === 'matrix'
? separator + joinedValues
: joinedValues;
};

View File

@@ -3,16 +3,26 @@
/**
* JSON-friendly union that mirrors what Pinia Colada can hash.
*/
export type JsonValue = null | string | number | boolean | JsonValue[] | { [key: string]: JsonValue };
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") {
if (
value === undefined ||
typeof value === 'function' ||
typeof value === 'symbol'
) {
return undefined;
}
if (typeof value === "bigint") {
if (typeof value === 'bigint') {
return value.toString();
}
if (value instanceof Date) {
@@ -40,7 +50,7 @@ export const stringifyToJsonValue = (input: unknown): JsonValue | 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") {
if (value === null || typeof value !== 'object') {
return false;
}
const prototype = Object.getPrototypeOf(value as object);
@@ -51,7 +61,9 @@ const isPlainObject = (value: unknown): value is Record<string, unknown> => {
* 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 entries = Array.from(params.entries()).sort(([a], [b]) =>
a.localeCompare(b),
);
const result: Record<string, JsonValue> = {};
for (const [key, value] of entries) {
@@ -74,20 +86,30 @@ const serializeSearchParams = (params: URLSearchParams): JsonValue => {
/**
* Normalizes any accepted value into a JSON-friendly shape for query keys.
*/
export const serializeQueryKeyValue = (value: unknown): JsonValue | undefined => {
export const serializeQueryKeyValue = (
value: unknown,
): JsonValue | undefined => {
if (value === null) {
return null;
}
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
if (
typeof value === 'string' ||
typeof value === 'number' ||
typeof value === 'boolean'
) {
return value;
}
if (value === undefined || typeof value === "function" || typeof value === "symbol") {
if (
value === undefined ||
typeof value === 'function' ||
typeof value === 'symbol'
) {
return undefined;
}
if (typeof value === "bigint") {
if (typeof value === 'bigint') {
return value.toString();
}
@@ -99,7 +121,10 @@ export const serializeQueryKeyValue = (value: unknown): JsonValue | undefined =>
return stringifyToJsonValue(value);
}
if (typeof URLSearchParams !== "undefined" && value instanceof URLSearchParams) {
if (
typeof URLSearchParams !== 'undefined' &&
value instanceof URLSearchParams
) {
return serializeSearchParams(value);
}

View File

@@ -1,9 +1,12 @@
// This file is auto-generated by @hey-api/openapi-ts
import type { Config } from "./types.gen";
import type { Config } from './types.gen';
export type ServerSentEventsOptions<TData = unknown> = Omit<RequestInit, "method"> &
Pick<Config, "method" | "responseTransformer" | "responseValidator"> & {
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.
@@ -32,7 +35,7 @@ export type ServerSentEventsOptions<TData = unknown> = Omit<RequestInit, "method
* @returns Nothing (void).
*/
onSseEvent?: (event: StreamEvent<TData>) => void;
serializedBody?: RequestInit["body"];
serializedBody?: RequestInit['body'];
/**
* Default retry delay in milliseconds.
*
@@ -71,8 +74,16 @@ export interface StreamEvent<TData = unknown> {
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 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>({
@@ -90,7 +101,9 @@ export const createSseClient = <TData = unknown>({
}: ServerSentEventsOptions): ServerSentEventsResult<TData> => {
let lastEventId: string | undefined;
const sleep = sseSleepFn ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms)));
const sleep =
sseSleepFn ??
((ms: number) => new Promise((resolve) => setTimeout(resolve, ms)));
const createStream = async function* () {
let retryDelay: number = sseDefaultRetryDelay ?? 3000;
@@ -108,12 +121,12 @@ export const createSseClient = <TData = unknown>({
: new Headers(options.headers as Record<string, string> | undefined);
if (lastEventId !== undefined) {
headers.set("Last-Event-ID", lastEventId);
headers.set('Last-Event-ID', lastEventId);
}
try {
const requestInit: RequestInit = {
redirect: "follow",
redirect: 'follow',
...options,
body: options.serializedBody,
headers,
@@ -128,13 +141,18 @@ export const createSseClient = <TData = unknown>({
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.ok)
throw new Error(
`SSE failed: ${response.status} ${response.statusText}`,
);
if (!response.body) throw new Error("No body in SSE response");
if (!response.body) throw new Error('No body in SSE response');
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
const reader = response.body
.pipeThrough(new TextDecoderStream())
.getReader();
let buffer = "";
let buffer = '';
const abortHandler = () => {
try {
@@ -144,7 +162,7 @@ export const createSseClient = <TData = unknown>({
}
};
signal.addEventListener("abort", abortHandler);
signal.addEventListener('abort', abortHandler);
try {
while (true) {
@@ -152,23 +170,26 @@ export const createSseClient = <TData = unknown>({
if (done) break;
buffer += value;
const chunks = buffer.split("\n\n");
buffer = chunks.pop() ?? "";
const chunks = buffer.split('\n\n');
buffer = chunks.pop() ?? '';
for (const chunk of chunks) {
const lines = chunk.split("\n");
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 (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;
}
@@ -179,7 +200,7 @@ export const createSseClient = <TData = unknown>({
let parsedJson = false;
if (dataLines.length) {
const rawData = dataLines.join("\n");
const rawData = dataLines.join('\n');
try {
data = JSON.parse(rawData);
parsedJson = true;
@@ -211,7 +232,7 @@ export const createSseClient = <TData = unknown>({
}
}
} finally {
signal.removeEventListener("abort", abortHandler);
signal.removeEventListener('abort', abortHandler);
reader.releaseLock();
}
@@ -220,12 +241,18 @@ export const createSseClient = <TData = unknown>({
// connection failed or aborted; retry after delay
onSseError?.(error);
if (sseMaxRetryAttempts !== undefined && attempt >= sseMaxRetryAttempts) {
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);
const backoff = Math.min(
retryDelay * 2 ** (attempt - 1),
sseMaxRetryDelay ?? 30000,
);
await sleep(backoff);
}
}

View File

@@ -1,11 +1,30 @@
// This file is auto-generated by @hey-api/openapi-ts
import type { Auth, AuthToken } from "./auth.gen";
import type { BodySerializer, QuerySerializer, QuerySerializerOptions } from "./bodySerializer.gen";
import type { Auth, AuthToken } from './auth.gen';
import type {
BodySerializer,
QuerySerializer,
QuerySerializerOptions,
} from './bodySerializer.gen';
export type HttpMethod = "connect" | "delete" | "get" | "head" | "options" | "patch" | "post" | "put" | "trace";
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> = {
export type Client<
RequestFn = never,
Config = unknown,
MethodFn = never,
BuildUrlFn = never,
SseFn = never,
> = {
/**
* Returns the final request URL.
*/
@@ -15,7 +34,9 @@ export type Client<RequestFn = never, Config = unknown, MethodFn = never, BuildU
setConfig: (config: Config) => Config;
} & {
[K in HttpMethod]: MethodFn;
} & ([SseFn] extends [never] ? { sse?: never } : { sse: { [K in HttpMethod]: SseFn } });
} & ([SseFn] extends [never]
? { sse?: never }
: { sse: { [K in HttpMethod]: SseFn } });
export interface Config {
/**
@@ -35,8 +56,17 @@ export interface Config {
* {@link https://developer.mozilla.org/docs/Web/API/Headers/Headers#init See more}
*/
headers?:
| RequestInit["headers"]
| Record<string, string | number | boolean | (string | number | boolean)[] | null | undefined | unknown>;
| RequestInit['headers']
| Record<
string,
| string
| number
| boolean
| (string | number | boolean)[]
| null
| undefined
| unknown
>;
/**
* The request method.
*
@@ -82,5 +112,7 @@ type IsExactlyNeverOrNeverUndefined<T> = [T] extends [never]
: false;
export type OmitNever<T extends Record<string, unknown>> = {
[K in keyof T as IsExactlyNeverOrNeverUndefined<T[K]> extends true ? never : K]: T[K];
[K in keyof T as IsExactlyNeverOrNeverUndefined<T[K]> extends true
? never
: K]: T[K];
};

View File

@@ -1,12 +1,12 @@
// This file is auto-generated by @hey-api/openapi-ts
import type { BodySerializer, QuerySerializer } from "./bodySerializer.gen";
import type { BodySerializer, QuerySerializer } from './bodySerializer.gen';
import {
type ArraySeparatorStyle,
serializeArrayParam,
serializeObjectParam,
serializePrimitiveParam,
} from "./pathSerializer.gen";
} from './pathSerializer.gen';
export interface PathSerializer {
path: Record<string, unknown>;
@@ -22,19 +22,19 @@ export const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => {
for (const match of matches) {
let explode = false;
let name = match.substring(1, match.length - 1);
let style: ArraySeparatorStyle = "simple";
let style: ArraySeparatorStyle = 'simple';
if (name.endsWith("*")) {
if (name.endsWith('*')) {
explode = true;
name = name.substring(0, name.length - 1);
}
if (name.startsWith(".")) {
if (name.startsWith('.')) {
name = name.substring(1);
style = "label";
} else if (name.startsWith(";")) {
style = 'label';
} else if (name.startsWith(';')) {
name = name.substring(1);
style = "matrix";
style = 'matrix';
}
const value = path[name];
@@ -44,11 +44,14 @@ export const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => {
}
if (Array.isArray(value)) {
url = url.replace(match, serializeArrayParam({ explode, name, style, value }));
url = url.replace(
match,
serializeArrayParam({ explode, name, style, value }),
);
continue;
}
if (typeof value === "object") {
if (typeof value === 'object') {
url = url.replace(
match,
serializeObjectParam({
@@ -62,7 +65,7 @@ export const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => {
continue;
}
if (style === "matrix") {
if (style === 'matrix') {
url = url.replace(
match,
`;${serializePrimitiveParam({
@@ -73,7 +76,9 @@ export const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => {
continue;
}
const replaceValue = encodeURIComponent(style === "label" ? `.${value as string}` : (value as string));
const replaceValue = encodeURIComponent(
style === 'label' ? `.${value as string}` : (value as string),
);
url = url.replace(match, replaceValue);
}
}
@@ -93,13 +98,13 @@ export const getUrl = ({
querySerializer: QuerySerializer;
url: string;
}) => {
const pathUrl = _url.startsWith("/") ? _url : `/${_url}`;
let url = (baseUrl ?? "") + pathUrl;
const pathUrl = _url.startsWith('/') ? _url : `/${_url}`;
let url = (baseUrl ?? '') + pathUrl;
if (path) {
url = defaultPathSerializer({ path, url });
}
let search = query ? querySerializer(query) : "";
if (search.startsWith("?")) {
let search = query ? querySerializer(query) : '';
if (search.startsWith('?')) {
search = search.substring(1);
}
if (search) {
@@ -117,14 +122,15 @@ export function getValidRequestBody(options: {
const isSerializedBody = hasBody && options.bodySerializer;
if (isSerializedBody) {
if ("serializedBody" in options) {
const hasSerializedBody = options.serializedBody !== undefined && options.serializedBody !== "";
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;
return options.body !== '' ? options.body : null;
}
// plain/text body

View File

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

View File

@@ -1,95 +1,10 @@
// This file is auto-generated by @hey-api/openapi-ts
import type { Client, Options as Options2, TDataShape } from "./client";
import { client } from "./client.gen";
import type {
BrowseFilesystemData,
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,
LoginResponses,
LogoutData,
LogoutResponses,
MountVolumeData,
MountVolumeResponses,
RegisterData,
RegisterResponses,
RestoreSnapshotData,
RestoreSnapshotResponses,
RunBackupNowData,
RunBackupNowResponses,
StopBackupData,
StopBackupErrors,
StopBackupResponses,
TestConnectionData,
TestConnectionResponses,
UnmountVolumeData,
UnmountVolumeResponses,
UpdateBackupScheduleData,
UpdateBackupScheduleResponses,
UpdateVolumeData,
UpdateVolumeErrors,
UpdateVolumeResponses,
} from "./types.gen";
import type { Client, Options as Options2, TDataShape } from './client';
import { client } from './client.gen';
import type { BrowseFilesystemData, BrowseFilesystemResponses, ChangePasswordData, ChangePasswordResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteSnapshotData, DeleteSnapshotResponses, 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, LoginResponses, LogoutData, LogoutResponses, MountVolumeData, MountVolumeResponses, RegisterData, RegisterResponses, RestoreSnapshotData, RestoreSnapshotResponses, RunBackupNowData, RunBackupNowResponses, RunForgetData, RunForgetResponses, StopBackupData, StopBackupErrors, StopBackupResponses, TestConnectionData, TestConnectionResponses, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, UpdateVolumeData, UpdateVolumeErrors, UpdateVolumeResponses } from './types.gen';
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<
TData,
ThrowOnError
> & {
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<TData, ThrowOnError> & {
/**
* You can provide a client instance returned by `createClient()` instead of
* individual options. This might be also useful if you want to implement a
@@ -108,12 +23,12 @@ export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends
*/
export const register = <ThrowOnError extends boolean = false>(options?: Options<RegisterData, ThrowOnError>) => {
return (options?.client ?? client).post<RegisterResponses, unknown, ThrowOnError>({
url: "/api/v1/auth/register",
url: '/api/v1/auth/register',
...options,
headers: {
"Content-Type": "application/json",
...options?.headers,
},
'Content-Type': 'application/json',
...options?.headers
}
});
};
@@ -122,12 +37,12 @@ export const register = <ThrowOnError extends boolean = false>(options?: Options
*/
export const login = <ThrowOnError extends boolean = false>(options?: Options<LoginData, ThrowOnError>) => {
return (options?.client ?? client).post<LoginResponses, unknown, ThrowOnError>({
url: "/api/v1/auth/login",
url: '/api/v1/auth/login',
...options,
headers: {
"Content-Type": "application/json",
...options?.headers,
},
'Content-Type': 'application/json',
...options?.headers
}
});
};
@@ -136,8 +51,8 @@ export const login = <ThrowOnError extends boolean = false>(options?: Options<Lo
*/
export const logout = <ThrowOnError extends boolean = false>(options?: Options<LogoutData, ThrowOnError>) => {
return (options?.client ?? client).post<LogoutResponses, unknown, ThrowOnError>({
url: "/api/v1/auth/logout",
...options,
url: '/api/v1/auth/logout',
...options
});
};
@@ -146,8 +61,8 @@ export const logout = <ThrowOnError extends boolean = false>(options?: Options<L
*/
export const getMe = <ThrowOnError extends boolean = false>(options?: Options<GetMeData, ThrowOnError>) => {
return (options?.client ?? client).get<GetMeResponses, unknown, ThrowOnError>({
url: "/api/v1/auth/me",
...options,
url: '/api/v1/auth/me',
...options
});
};
@@ -156,24 +71,22 @@ export const getMe = <ThrowOnError extends boolean = false>(options?: Options<Ge
*/
export const getStatus = <ThrowOnError extends boolean = false>(options?: Options<GetStatusData, ThrowOnError>) => {
return (options?.client ?? client).get<GetStatusResponses, unknown, ThrowOnError>({
url: "/api/v1/auth/status",
...options,
url: '/api/v1/auth/status',
...options
});
};
/**
* Change current user password
*/
export const changePassword = <ThrowOnError extends boolean = false>(
options?: Options<ChangePasswordData, ThrowOnError>,
) => {
export const changePassword = <ThrowOnError extends boolean = false>(options?: Options<ChangePasswordData, ThrowOnError>) => {
return (options?.client ?? client).post<ChangePasswordResponses, unknown, ThrowOnError>({
url: "/api/v1/auth/change-password",
url: '/api/v1/auth/change-password',
...options,
headers: {
"Content-Type": "application/json",
...options?.headers,
},
'Content-Type': 'application/json',
...options?.headers
}
});
};
@@ -182,52 +95,46 @@ export const changePassword = <ThrowOnError extends boolean = false>(
*/
export const listVolumes = <ThrowOnError extends boolean = false>(options?: Options<ListVolumesData, ThrowOnError>) => {
return (options?.client ?? client).get<ListVolumesResponses, unknown, ThrowOnError>({
url: "/api/v1/volumes",
...options,
url: '/api/v1/volumes',
...options
});
};
/**
* Create a new volume
*/
export const createVolume = <ThrowOnError extends boolean = false>(
options?: Options<CreateVolumeData, ThrowOnError>,
) => {
export const createVolume = <ThrowOnError extends boolean = false>(options?: Options<CreateVolumeData, ThrowOnError>) => {
return (options?.client ?? client).post<CreateVolumeResponses, unknown, ThrowOnError>({
url: "/api/v1/volumes",
url: '/api/v1/volumes',
...options,
headers: {
"Content-Type": "application/json",
...options?.headers,
},
'Content-Type': 'application/json',
...options?.headers
}
});
};
/**
* Test connection to backend
*/
export const testConnection = <ThrowOnError extends boolean = false>(
options?: Options<TestConnectionData, ThrowOnError>,
) => {
export const testConnection = <ThrowOnError extends boolean = false>(options?: Options<TestConnectionData, ThrowOnError>) => {
return (options?.client ?? client).post<TestConnectionResponses, unknown, ThrowOnError>({
url: "/api/v1/volumes/test-connection",
url: '/api/v1/volumes/test-connection',
...options,
headers: {
"Content-Type": "application/json",
...options?.headers,
},
'Content-Type': 'application/json',
...options?.headers
}
});
};
/**
* Delete a volume
*/
export const deleteVolume = <ThrowOnError extends boolean = false>(
options: Options<DeleteVolumeData, ThrowOnError>,
) => {
export const deleteVolume = <ThrowOnError extends boolean = false>(options: Options<DeleteVolumeData, ThrowOnError>) => {
return (options.client ?? client).delete<DeleteVolumeResponses, unknown, ThrowOnError>({
url: "/api/v1/volumes/{name}",
...options,
url: '/api/v1/volumes/{name}',
...options
});
};
@@ -236,40 +143,32 @@ export const deleteVolume = <ThrowOnError extends boolean = false>(
*/
export const getVolume = <ThrowOnError extends boolean = false>(options: Options<GetVolumeData, ThrowOnError>) => {
return (options.client ?? client).get<GetVolumeResponses, GetVolumeErrors, ThrowOnError>({
url: "/api/v1/volumes/{name}",
...options,
url: '/api/v1/volumes/{name}',
...options
});
};
/**
* Update a volume's configuration
*/
export const updateVolume = <ThrowOnError extends boolean = false>(
options: Options<UpdateVolumeData, ThrowOnError>,
) => {
export const updateVolume = <ThrowOnError extends boolean = false>(options: Options<UpdateVolumeData, ThrowOnError>) => {
return (options.client ?? client).put<UpdateVolumeResponses, UpdateVolumeErrors, ThrowOnError>({
url: "/api/v1/volumes/{name}",
url: '/api/v1/volumes/{name}',
...options,
headers: {
"Content-Type": "application/json",
...options.headers,
},
'Content-Type': 'application/json',
...options.headers
}
});
};
/**
* Get containers using a volume by name
*/
export const getContainersUsingVolume = <ThrowOnError extends boolean = false>(
options: Options<GetContainersUsingVolumeData, ThrowOnError>,
) => {
return (options.client ?? client).get<
GetContainersUsingVolumeResponses,
GetContainersUsingVolumeErrors,
ThrowOnError
>({
url: "/api/v1/volumes/{name}/containers",
...options,
export const getContainersUsingVolume = <ThrowOnError extends boolean = false>(options: Options<GetContainersUsingVolumeData, ThrowOnError>) => {
return (options.client ?? client).get<GetContainersUsingVolumeResponses, GetContainersUsingVolumeErrors, ThrowOnError>({
url: '/api/v1/volumes/{name}/containers',
...options
});
};
@@ -278,32 +177,28 @@ export const getContainersUsingVolume = <ThrowOnError extends boolean = false>(
*/
export const mountVolume = <ThrowOnError extends boolean = false>(options: Options<MountVolumeData, ThrowOnError>) => {
return (options.client ?? client).post<MountVolumeResponses, unknown, ThrowOnError>({
url: "/api/v1/volumes/{name}/mount",
...options,
url: '/api/v1/volumes/{name}/mount',
...options
});
};
/**
* Unmount a volume
*/
export const unmountVolume = <ThrowOnError extends boolean = false>(
options: Options<UnmountVolumeData, ThrowOnError>,
) => {
export const unmountVolume = <ThrowOnError extends boolean = false>(options: Options<UnmountVolumeData, ThrowOnError>) => {
return (options.client ?? client).post<UnmountVolumeResponses, unknown, ThrowOnError>({
url: "/api/v1/volumes/{name}/unmount",
...options,
url: '/api/v1/volumes/{name}/unmount',
...options
});
};
/**
* Perform a health check on a volume
*/
export const healthCheckVolume = <ThrowOnError extends boolean = false>(
options: Options<HealthCheckVolumeData, ThrowOnError>,
) => {
export const healthCheckVolume = <ThrowOnError extends boolean = false>(options: Options<HealthCheckVolumeData, ThrowOnError>) => {
return (options.client ?? client).post<HealthCheckVolumeResponses, HealthCheckVolumeErrors, ThrowOnError>({
url: "/api/v1/volumes/{name}/health-check",
...options,
url: '/api/v1/volumes/{name}/health-check',
...options
});
};
@@ -312,240 +207,214 @@ export const healthCheckVolume = <ThrowOnError extends boolean = false>(
*/
export const listFiles = <ThrowOnError extends boolean = false>(options: Options<ListFilesData, ThrowOnError>) => {
return (options.client ?? client).get<ListFilesResponses, unknown, ThrowOnError>({
url: "/api/v1/volumes/{name}/files",
...options,
url: '/api/v1/volumes/{name}/files',
...options
});
};
/**
* Browse directories on the host filesystem
*/
export const browseFilesystem = <ThrowOnError extends boolean = false>(
options?: Options<BrowseFilesystemData, ThrowOnError>,
) => {
export const browseFilesystem = <ThrowOnError extends boolean = false>(options?: Options<BrowseFilesystemData, ThrowOnError>) => {
return (options?.client ?? client).get<BrowseFilesystemResponses, unknown, ThrowOnError>({
url: "/api/v1/volumes/filesystem/browse",
...options,
url: '/api/v1/volumes/filesystem/browse',
...options
});
};
/**
* List all repositories
*/
export const listRepositories = <ThrowOnError extends boolean = false>(
options?: Options<ListRepositoriesData, ThrowOnError>,
) => {
export const listRepositories = <ThrowOnError extends boolean = false>(options?: Options<ListRepositoriesData, ThrowOnError>) => {
return (options?.client ?? client).get<ListRepositoriesResponses, unknown, ThrowOnError>({
url: "/api/v1/repositories",
...options,
url: '/api/v1/repositories',
...options
});
};
/**
* Create a new restic repository
*/
export const createRepository = <ThrowOnError extends boolean = false>(
options?: Options<CreateRepositoryData, ThrowOnError>,
) => {
export const createRepository = <ThrowOnError extends boolean = false>(options?: Options<CreateRepositoryData, ThrowOnError>) => {
return (options?.client ?? client).post<CreateRepositoryResponses, unknown, ThrowOnError>({
url: "/api/v1/repositories",
url: '/api/v1/repositories',
...options,
headers: {
"Content-Type": "application/json",
...options?.headers,
},
'Content-Type': 'application/json',
...options?.headers
}
});
};
/**
* List all configured rclone remotes on the host system
*/
export const listRcloneRemotes = <ThrowOnError extends boolean = false>(
options?: Options<ListRcloneRemotesData, ThrowOnError>,
) => {
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,
url: '/api/v1/repositories/rclone-remotes',
...options
});
};
/**
* Delete a repository
*/
export const deleteRepository = <ThrowOnError extends boolean = false>(
options: Options<DeleteRepositoryData, ThrowOnError>,
) => {
export const deleteRepository = <ThrowOnError extends boolean = false>(options: Options<DeleteRepositoryData, ThrowOnError>) => {
return (options.client ?? client).delete<DeleteRepositoryResponses, unknown, ThrowOnError>({
url: "/api/v1/repositories/{name}",
...options,
url: '/api/v1/repositories/{name}',
...options
});
};
/**
* Get a single repository by name
*/
export const getRepository = <ThrowOnError extends boolean = false>(
options: Options<GetRepositoryData, ThrowOnError>,
) => {
export const getRepository = <ThrowOnError extends boolean = false>(options: Options<GetRepositoryData, ThrowOnError>) => {
return (options.client ?? client).get<GetRepositoryResponses, unknown, ThrowOnError>({
url: "/api/v1/repositories/{name}",
...options,
url: '/api/v1/repositories/{name}',
...options
});
};
/**
* List all snapshots in a repository
*/
export const listSnapshots = <ThrowOnError extends boolean = false>(
options: Options<ListSnapshotsData, ThrowOnError>,
) => {
export const listSnapshots = <ThrowOnError extends boolean = false>(options: Options<ListSnapshotsData, ThrowOnError>) => {
return (options.client ?? client).get<ListSnapshotsResponses, unknown, ThrowOnError>({
url: "/api/v1/repositories/{name}/snapshots",
...options,
url: '/api/v1/repositories/{name}/snapshots',
...options
});
};
/**
* Delete a specific snapshot from a repository
*/
export const deleteSnapshot = <ThrowOnError extends boolean = false>(options: Options<DeleteSnapshotData, ThrowOnError>) => {
return (options.client ?? client).delete<DeleteSnapshotResponses, unknown, ThrowOnError>({
url: '/api/v1/repositories/{name}/snapshots/{snapshotId}',
...options
});
};
/**
* Get details of a specific snapshot
*/
export const getSnapshotDetails = <ThrowOnError extends boolean = false>(
options: Options<GetSnapshotDetailsData, ThrowOnError>,
) => {
export const getSnapshotDetails = <ThrowOnError extends boolean = false>(options: Options<GetSnapshotDetailsData, ThrowOnError>) => {
return (options.client ?? client).get<GetSnapshotDetailsResponses, unknown, ThrowOnError>({
url: "/api/v1/repositories/{name}/snapshots/{snapshotId}",
...options,
url: '/api/v1/repositories/{name}/snapshots/{snapshotId}',
...options
});
};
/**
* List files and directories in a snapshot
*/
export const listSnapshotFiles = <ThrowOnError extends boolean = false>(
options: Options<ListSnapshotFilesData, ThrowOnError>,
) => {
export const listSnapshotFiles = <ThrowOnError extends boolean = false>(options: Options<ListSnapshotFilesData, ThrowOnError>) => {
return (options.client ?? client).get<ListSnapshotFilesResponses, unknown, ThrowOnError>({
url: "/api/v1/repositories/{name}/snapshots/{snapshotId}/files",
...options,
url: '/api/v1/repositories/{name}/snapshots/{snapshotId}/files',
...options
});
};
/**
* Restore a snapshot to a target path on the filesystem
*/
export const restoreSnapshot = <ThrowOnError extends boolean = false>(
options: Options<RestoreSnapshotData, ThrowOnError>,
) => {
export const restoreSnapshot = <ThrowOnError extends boolean = false>(options: Options<RestoreSnapshotData, ThrowOnError>) => {
return (options.client ?? client).post<RestoreSnapshotResponses, unknown, ThrowOnError>({
url: "/api/v1/repositories/{name}/restore",
url: '/api/v1/repositories/{name}/restore',
...options,
headers: {
"Content-Type": "application/json",
...options.headers,
},
'Content-Type': 'application/json',
...options.headers
}
});
};
/**
* Run doctor operations on a repository to fix common issues (unlock, check, repair index). Use this when the repository is locked or has errors.
*/
export const doctorRepository = <ThrowOnError extends boolean = false>(
options: Options<DoctorRepositoryData, ThrowOnError>,
) => {
export const doctorRepository = <ThrowOnError extends boolean = false>(options: Options<DoctorRepositoryData, ThrowOnError>) => {
return (options.client ?? client).post<DoctorRepositoryResponses, unknown, ThrowOnError>({
url: "/api/v1/repositories/{name}/doctor",
...options,
url: '/api/v1/repositories/{name}/doctor',
...options
});
};
/**
* List all backup schedules
*/
export const listBackupSchedules = <ThrowOnError extends boolean = false>(
options?: Options<ListBackupSchedulesData, ThrowOnError>,
) => {
export const listBackupSchedules = <ThrowOnError extends boolean = false>(options?: Options<ListBackupSchedulesData, ThrowOnError>) => {
return (options?.client ?? client).get<ListBackupSchedulesResponses, unknown, ThrowOnError>({
url: "/api/v1/backups",
...options,
url: '/api/v1/backups',
...options
});
};
/**
* Create a new backup schedule for a volume
*/
export const createBackupSchedule = <ThrowOnError extends boolean = false>(
options?: Options<CreateBackupScheduleData, ThrowOnError>,
) => {
export const createBackupSchedule = <ThrowOnError extends boolean = false>(options?: Options<CreateBackupScheduleData, ThrowOnError>) => {
return (options?.client ?? client).post<CreateBackupScheduleResponses, unknown, ThrowOnError>({
url: "/api/v1/backups",
url: '/api/v1/backups',
...options,
headers: {
"Content-Type": "application/json",
...options?.headers,
},
'Content-Type': 'application/json',
...options?.headers
}
});
};
/**
* Delete a backup schedule
*/
export const deleteBackupSchedule = <ThrowOnError extends boolean = false>(
options: Options<DeleteBackupScheduleData, ThrowOnError>,
) => {
export const deleteBackupSchedule = <ThrowOnError extends boolean = false>(options: Options<DeleteBackupScheduleData, ThrowOnError>) => {
return (options.client ?? client).delete<DeleteBackupScheduleResponses, unknown, ThrowOnError>({
url: "/api/v1/backups/{scheduleId}",
...options,
url: '/api/v1/backups/{scheduleId}',
...options
});
};
/**
* Get a backup schedule by ID
*/
export const getBackupSchedule = <ThrowOnError extends boolean = false>(
options: Options<GetBackupScheduleData, ThrowOnError>,
) => {
export const getBackupSchedule = <ThrowOnError extends boolean = false>(options: Options<GetBackupScheduleData, ThrowOnError>) => {
return (options.client ?? client).get<GetBackupScheduleResponses, unknown, ThrowOnError>({
url: "/api/v1/backups/{scheduleId}",
...options,
url: '/api/v1/backups/{scheduleId}',
...options
});
};
/**
* Update a backup schedule
*/
export const updateBackupSchedule = <ThrowOnError extends boolean = false>(
options: Options<UpdateBackupScheduleData, ThrowOnError>,
) => {
export const updateBackupSchedule = <ThrowOnError extends boolean = false>(options: Options<UpdateBackupScheduleData, ThrowOnError>) => {
return (options.client ?? client).patch<UpdateBackupScheduleResponses, unknown, ThrowOnError>({
url: "/api/v1/backups/{scheduleId}",
url: '/api/v1/backups/{scheduleId}',
...options,
headers: {
"Content-Type": "application/json",
...options.headers,
},
'Content-Type': 'application/json',
...options.headers
}
});
};
/**
* Get a backup schedule for a specific volume
*/
export const getBackupScheduleForVolume = <ThrowOnError extends boolean = false>(
options: Options<GetBackupScheduleForVolumeData, ThrowOnError>,
) => {
export const getBackupScheduleForVolume = <ThrowOnError extends boolean = false>(options: Options<GetBackupScheduleForVolumeData, ThrowOnError>) => {
return (options.client ?? client).get<GetBackupScheduleForVolumeResponses, unknown, ThrowOnError>({
url: "/api/v1/backups/volume/{volumeId}",
...options,
url: '/api/v1/backups/volume/{volumeId}',
...options
});
};
/**
* Trigger a backup immediately for a schedule
*/
export const runBackupNow = <ThrowOnError extends boolean = false>(
options: Options<RunBackupNowData, ThrowOnError>,
) => {
export const runBackupNow = <ThrowOnError extends boolean = false>(options: Options<RunBackupNowData, ThrowOnError>) => {
return (options.client ?? client).post<RunBackupNowResponses, unknown, ThrowOnError>({
url: "/api/v1/backups/{scheduleId}/run",
...options,
url: '/api/v1/backups/{scheduleId}/run',
...options
});
};
@@ -554,35 +423,41 @@ export const runBackupNow = <ThrowOnError extends boolean = false>(
*/
export const stopBackup = <ThrowOnError extends boolean = false>(options: Options<StopBackupData, ThrowOnError>) => {
return (options.client ?? client).post<StopBackupResponses, StopBackupErrors, ThrowOnError>({
url: "/api/v1/backups/{scheduleId}/stop",
...options,
url: '/api/v1/backups/{scheduleId}/stop',
...options
});
};
/**
* Manually apply retention policy to clean up old snapshots
*/
export const runForget = <ThrowOnError extends boolean = false>(options: Options<RunForgetData, ThrowOnError>) => {
return (options.client ?? client).post<RunForgetResponses, unknown, ThrowOnError>({
url: '/api/v1/backups/{scheduleId}/forget',
...options
});
};
/**
* Get system information including available capabilities
*/
export const getSystemInfo = <ThrowOnError extends boolean = false>(
options?: Options<GetSystemInfoData, ThrowOnError>,
) => {
export const getSystemInfo = <ThrowOnError extends boolean = false>(options?: Options<GetSystemInfoData, ThrowOnError>) => {
return (options?.client ?? client).get<GetSystemInfoResponses, unknown, ThrowOnError>({
url: "/api/v1/system/info",
...options,
url: '/api/v1/system/info',
...options
});
};
/**
* Download the Restic password file for backup recovery. Requires password re-authentication.
*/
export const downloadResticPassword = <ThrowOnError extends boolean = false>(
options?: Options<DownloadResticPasswordData, ThrowOnError>,
) => {
export const downloadResticPassword = <ThrowOnError extends boolean = false>(options?: Options<DownloadResticPasswordData, ThrowOnError>) => {
return (options?.client ?? client).post<DownloadResticPasswordResponses, unknown, ThrowOnError>({
url: "/api/v1/system/restic-password",
url: '/api/v1/system/restic-password',
...options,
headers: {
"Content-Type": "application/json",
...options?.headers,
},
'Content-Type': 'application/json',
...options?.headers
}
});
};

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
import { Link } from "react-router";
import { Link, useMatches, type UIMatch } from "react-router";
import {
Breadcrumb,
BreadcrumbItem,
@@ -7,14 +7,38 @@ import {
BreadcrumbPage,
BreadcrumbSeparator,
} from "~/client/components/ui/breadcrumb";
import { useBreadcrumbs } from "~/client/lib/breadcrumbs";
export interface BreadcrumbItemData {
label: string;
href?: string;
}
interface RouteHandle {
breadcrumb?: (match: UIMatch) => BreadcrumbItemData[] | null;
}
export function AppBreadcrumb() {
const breadcrumbs = useBreadcrumbs();
const matches = useMatches();
// Find the last match with a breadcrumb handler
const lastMatchWithBreadcrumb = [...matches].reverse().find((match) => {
const handle = match.handle as RouteHandle | undefined;
return handle?.breadcrumb;
});
if (!lastMatchWithBreadcrumb) {
return null;
}
const handle = lastMatchWithBreadcrumb.handle as RouteHandle;
const breadcrumbs = handle.breadcrumb?.(lastMatchWithBreadcrumb);
if (!breadcrumbs || breadcrumbs.length === 0) {
return null;
}
return (
<Breadcrumb>
<BreadcrumbLink asChild></BreadcrumbLink>
<BreadcrumbList>
{breadcrumbs.map((breadcrumb, index) => {
const isLast = index === breadcrumbs.length - 1;
@@ -22,14 +46,12 @@ export function AppBreadcrumb() {
return (
<div key={`${breadcrumb.label}-${index}`} className="contents">
<BreadcrumbItem>
{isLast || breadcrumb.isCurrentPage ? (
{isLast || !breadcrumb.href ? (
<BreadcrumbPage>{breadcrumb.label}</BreadcrumbPage>
) : breadcrumb.href ? (
) : (
<BreadcrumbLink asChild>
<Link to={breadcrumb.href}>{breadcrumb.label}</Link>
</BreadcrumbLink>
) : (
<BreadcrumbPage>{breadcrumb.label}</BreadcrumbPage>
)}
</BreadcrumbItem>
{!isLast && <BreadcrumbSeparator />}

View File

@@ -3,6 +3,7 @@ import { Link, NavLink } from "react-router";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarHeader,
@@ -13,6 +14,7 @@ import {
} from "~/client/components/ui/sidebar";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "~/client/components/ui/tooltip";
import { cn } from "~/client/lib/utils";
import { APP_VERSION } from "~/client/lib/version";
const items = [
{
@@ -85,6 +87,15 @@ export function AppSidebar() {
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter className="p-4 border-r border-t border-border/50">
<div
className={cn("text-xs text-muted-foreground transition-all duration-200", {
"opacity-0 w-0 overflow-hidden": state === "collapsed",
})}
>
Version {APP_VERSION}
</div>
</SidebarFooter>
</Sidebar>
);
}

View File

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

View File

@@ -1,6 +1,6 @@
import { arktypeResolver } from "@hookform/resolvers/arktype";
import { type } from "arktype";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { cn, slugify } from "~/client/lib/utils";
import { deepClean } from "~/utils/object";
@@ -15,6 +15,7 @@ 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";
import { Checkbox } from "./ui/checkbox";
export const formSchema = type({
name: "2<=string<=32",
@@ -36,6 +37,7 @@ type Props = {
const defaultValuesForType = {
local: { backend: "local" as const, compressionMode: "auto" as const },
s3: { backend: "s3" as const, compressionMode: "auto" as const },
r2: { backend: "r2" 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 },
@@ -58,23 +60,29 @@ export const CreateRepositoryForm = ({
},
});
const { watch } = form;
const { watch, setValue } = form;
const watchedBackend = watch("backend");
const watchedIsExistingRepository = watch("isExistingRepository");
const [passwordMode, setPasswordMode] = useState<"default" | "custom">("default");
const { capabilities } = useSystemInfo();
const { data: rcloneRemotes, isLoading: isLoadingRemotes } = useQuery({
...listRcloneRemotesOptions(),
enabled: capabilities.rclone,
});
useEffect(() => {
form.reset({
name: form.getValues().name,
isExistingRepository: form.getValues().isExistingRepository,
customPassword: form.getValues().customPassword,
...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)}>
@@ -115,6 +123,7 @@ export const CreateRepositoryForm = ({
<SelectContent>
<SelectItem value="local">Local</SelectItem>
<SelectItem value="s3">S3</SelectItem>
<SelectItem value="r2">Cloudflare R2</SelectItem>
<SelectItem value="gcs">Google Cloud Storage</SelectItem>
<SelectItem value="azure">Azure Blob Storage</SelectItem>
<Tooltip>
@@ -161,6 +170,81 @@ export const CreateRepositoryForm = ({
)}
/>
<FormField
control={form.control}
name="isExistingRepository"
render={({ field }) => (
<FormItem className="flex flex-row items-center space-x-3">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={(checked) => {
field.onChange(checked);
if (!checked) {
setPasswordMode("default");
setValue("customPassword", undefined);
}
}}
/>
</FormControl>
<div className="space-y-1">
<FormLabel>Import existing repository</FormLabel>
<FormDescription>Check this if the repository already exists at the specified location</FormDescription>
</div>
</FormItem>
)}
/>
{watchedIsExistingRepository && (
<>
<FormItem>
<FormLabel>Repository Password</FormLabel>
<Select
onValueChange={(value) => {
setPasswordMode(value as "default" | "custom");
if (value === "default") {
setValue("customPassword", undefined);
}
}}
defaultValue={passwordMode}
value={passwordMode}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select password option" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="default">Use Ironmount's password</SelectItem>
<SelectItem value="custom">Enter password manually</SelectItem>
</SelectContent>
</Select>
<FormDescription>
Choose whether to use Ironmount's master password or enter a custom password for the existing
repository.
</FormDescription>
</FormItem>
{passwordMode === "custom" && (
<FormField
control={form.control}
name="customPassword"
render={({ field }) => (
<FormItem>
<FormLabel>Repository Password</FormLabel>
<FormControl>
<Input type="password" placeholder="Enter repository password" {...field} />
</FormControl>
<FormDescription>
The password used to encrypt this repository. It will be stored securely.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
</>
)}
{watchedBackend === "s3" && (
<>
<FormField
@@ -222,6 +306,69 @@ export const CreateRepositoryForm = ({
</>
)}
{watchedBackend === "r2" && (
<>
<FormField
control={form.control}
name="endpoint"
render={({ field }) => (
<FormItem>
<FormLabel>Endpoint</FormLabel>
<FormControl>
<Input placeholder="<account-id>.r2.cloudflarestorage.com" {...field} />
</FormControl>
<FormDescription>
R2 endpoint (without https://). Find in R2 dashboard under bucket settings.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="bucket"
render={({ field }) => (
<FormItem>
<FormLabel>Bucket</FormLabel>
<FormControl>
<Input placeholder="my-backup-bucket" {...field} />
</FormControl>
<FormDescription>R2 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="Access Key ID from R2 API tokens" {...field} />
</FormControl>
<FormDescription>R2 API token Access Key ID (create in Cloudflare R2 dashboard).</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>R2 API token Secret Access Key (shown once when creating token).</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{watchedBackend === "gcs" && (
<>
<FormField

View File

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

View File

@@ -536,6 +536,7 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
</>
)}
{watchedBackend !== "directory" && (
<div className="space-y-3">
<div className="flex items-center gap-2">
<Button
@@ -572,6 +573,7 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
</div>
)}
</div>
)}
{mode === "update" && (
<Button type="submit" className="w-full" loading={loading}>
Save Changes

View File

@@ -1,8 +1,8 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useCallback, useMemo, useState } from "react";
import { FileTree, type FileEntry } from "./file-tree";
import { FileTree } from "./file-tree";
import { ScrollArea } from "./ui/scroll-area";
import { browseFilesystemOptions } from "../api-client/@tanstack/react-query.gen";
import { useFileBrowser } from "../hooks/use-file-browser";
type Props = {
onSelectPath: (path: string) => void;
@@ -11,82 +11,23 @@ type Props = {
export const DirectoryBrowser = ({ onSelectPath, selectedPath }: Props) => {
const queryClient = useQueryClient();
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
const [fetchedFolders, setFetchedFolders] = useState<Set<string>>(new Set(["/"]));
const [loadingFolders, setLoadingFolders] = useState<Set<string>>(new Set());
const [allFiles, setAllFiles] = useState<Map<string, FileEntry>>(new Map());
const { data, isLoading } = useQuery({
...browseFilesystemOptions({ query: { path: "/" } }),
});
useMemo(() => {
if (data?.directories) {
setAllFiles((prev) => {
const next = new Map(prev);
for (const dir of data.directories) {
next.set(dir.path, { name: dir.name, path: dir.path, type: "folder" });
}
return next;
});
}
}, [data]);
const fileArray = useMemo(() => Array.from(allFiles.values()), [allFiles]);
const handleFolderExpand = useCallback(
async (folderPath: string) => {
setExpandedFolders((prev) => {
const next = new Set(prev);
next.add(folderPath);
return next;
});
if (!fetchedFolders.has(folderPath)) {
setLoadingFolders((prev) => new Set(prev).add(folderPath));
try {
const result = await queryClient.ensureQueryData(
browseFilesystemOptions({
query: { path: folderPath },
}),
);
if (result.directories) {
setAllFiles((prev) => {
const next = new Map(prev);
for (const dir of result.directories) {
next.set(dir.path, { name: dir.name, path: dir.path, type: "folder" });
}
return next;
});
setFetchedFolders((prev) => new Set(prev).add(folderPath));
}
} catch (error) {
console.error("Failed to fetch folder contents:", error);
} finally {
setLoadingFolders((prev) => {
const next = new Set(prev);
next.delete(folderPath);
return next;
});
}
}
const fileBrowser = useFileBrowser({
initialData: data,
isLoading,
fetchFolder: async (path) => {
return await queryClient.ensureQueryData(browseFilesystemOptions({ query: { path } }));
},
[fetchedFolders, queryClient],
);
const handleFolderHover = useCallback(
(folderPath: string) => {
if (!fetchedFolders.has(folderPath) && !loadingFolders.has(folderPath)) {
queryClient.prefetchQuery(browseFilesystemOptions({ query: { path: folderPath } }));
}
prefetchFolder: (path) => {
queryClient.prefetchQuery(browseFilesystemOptions({ query: { path } }));
},
[fetchedFolders, loadingFolders, queryClient],
);
});
if (isLoading && fileArray.length === 0) {
if (fileBrowser.isLoading) {
return (
<div className="border rounded-lg overflow-hidden">
<ScrollArea className="h-64">
@@ -96,7 +37,7 @@ export const DirectoryBrowser = ({ onSelectPath, selectedPath }: Props) => {
);
}
if (fileArray.length === 0) {
if (fileBrowser.isEmpty) {
return (
<div className="border rounded-lg overflow-hidden">
<ScrollArea className="h-64">
@@ -110,11 +51,11 @@ export const DirectoryBrowser = ({ onSelectPath, selectedPath }: Props) => {
<div className="border rounded-lg overflow-hidden">
<ScrollArea className="h-64">
<FileTree
files={fileArray}
onFolderExpand={handleFolderExpand}
onFolderHover={handleFolderHover}
expandedFolders={expandedFolders}
loadingFolders={loadingFolders}
files={fileBrowser.fileArray}
onFolderExpand={fileBrowser.handleFolderExpand}
onFolderHover={fileBrowser.handleFolderHover}
expandedFolders={fileBrowser.expandedFolders}
loadingFolders={fileBrowser.loadingFolders}
foldersOnly
selectableFolders
selectedFolder={selectedPath}

View File

@@ -1,10 +1,26 @@
import { Calendar, Clock, Database, FolderTree, HardDrive } from "lucide-react";
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Calendar, Clock, Database, FolderTree, HardDrive, Trash2 } from "lucide-react";
import { useNavigate } from "react-router";
import { toast } from "sonner";
import { ByteSize } from "~/client/components/bytes-size";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/client/components/ui/table";
import { Tooltip, TooltipContent, TooltipTrigger } from "~/client/components/ui/tooltip";
import { Button } from "~/client/components/ui/button";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "~/client/components/ui/alert-dialog";
import { formatDuration } from "~/utils/utils";
import type { ListSnapshotsResponse } from "../api-client";
import { deleteSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen";
import { parseError } from "~/client/lib/errors";
type Snapshot = ListSnapshotsResponse[number];
@@ -15,12 +31,46 @@ type Props = {
export const SnapshotsTable = ({ snapshots, repositoryName }: Props) => {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [snapshotToDelete, setSnapshotToDelete] = useState<string | null>(null);
const deleteSnapshot = useMutation({
...deleteSnapshotMutation(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["listSnapshots"] });
setShowDeleteConfirm(false);
setSnapshotToDelete(null);
},
});
const handleDeleteClick = (e: React.MouseEvent, snapshotId: string) => {
e.stopPropagation();
setSnapshotToDelete(snapshotId);
setShowDeleteConfirm(true);
};
const handleConfirmDelete = () => {
if (snapshotToDelete) {
toast.promise(
deleteSnapshot.mutateAsync({
path: { name: repositoryName, snapshotId: snapshotToDelete },
}),
{
loading: "Deleting snapshot...",
success: "Snapshot deleted successfully",
error: (error) => parseError(error)?.message || "Failed to delete snapshot",
},
);
}
};
const handleRowClick = (snapshotId: string) => {
navigate(`/repositories/${repositoryName}/${snapshotId}`);
};
return (
<>
<div className="overflow-x-auto">
<Table className="border-t">
<TableHeader className="bg-card-header">
@@ -30,6 +80,7 @@ export const SnapshotsTable = ({ snapshots, repositoryName }: Props) => {
<TableHead className="uppercase">Size</TableHead>
<TableHead className="uppercase hidden md:table-cell text-right">Duration</TableHead>
<TableHead className="uppercase hidden text-right lg:table-cell">Paths</TableHead>
<TableHead className="uppercase text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -86,10 +137,43 @@ export const SnapshotsTable = ({ snapshots, repositoryName }: Props) => {
</Tooltip>
</div>
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
onClick={(e) => handleDeleteClick(e, snapshot.short_id)}
disabled={deleteSnapshot.isPending}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete snapshot?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete the snapshot and all its data from the
repository.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmDelete}
disabled={deleteSnapshot.isPending}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete snapshot
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
};

View File

@@ -1,16 +1,8 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { FolderOpen } from "lucide-react";
import { useCallback, useMemo, useState } from "react";
import { FileTree } from "~/client/components/file-tree";
import { listFilesOptions } from "../api-client/@tanstack/react-query.gen";
interface FileEntry {
name: string;
path: string;
type: "file" | "directory";
size?: number;
modifiedAt?: number;
}
import { useFileBrowser } from "../hooks/use-file-browser";
type VolumeFileBrowserProps = {
volumeName: string;
@@ -36,89 +28,34 @@ export const VolumeFileBrowser = ({
emptyDescription,
}: VolumeFileBrowserProps) => {
const queryClient = useQueryClient();
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
const [fetchedFolders, setFetchedFolders] = useState<Set<string>>(new Set(["/"]));
const [loadingFolders, setLoadingFolders] = useState<Set<string>>(new Set());
const [allFiles, setAllFiles] = useState<Map<string, FileEntry>>(new Map());
const { data, isLoading, error } = useQuery({
...listFilesOptions({ path: { name: volumeName } }),
enabled,
});
useMemo(() => {
if (data?.files) {
setAllFiles((prev) => {
const next = new Map(prev);
for (const file of data.files) {
next.set(file.path, file);
}
return next;
});
}
}, [data]);
const fileArray = useMemo(() => Array.from(allFiles.values()), [allFiles]);
const handleFolderExpand = useCallback(
async (folderPath: string) => {
setExpandedFolders((prev) => {
const next = new Set(prev);
next.add(folderPath);
return next;
});
if (!fetchedFolders.has(folderPath)) {
setLoadingFolders((prev) => new Set(prev).add(folderPath));
try {
const result = await queryClient.ensureQueryData(
const fileBrowser = useFileBrowser({
initialData: data,
isLoading,
fetchFolder: async (path) => {
return await queryClient.ensureQueryData(
listFilesOptions({
path: { name: volumeName },
query: { path: folderPath },
query: { path },
}),
);
if (result.files) {
setAllFiles((prev) => {
const next = new Map(prev);
for (const file of result.files) {
next.set(file.path, file);
}
return next;
});
setFetchedFolders((prev) => new Set(prev).add(folderPath));
}
} catch (error) {
console.error("Failed to fetch folder contents:", error);
} finally {
setLoadingFolders((prev) => {
const next = new Set(prev);
next.delete(folderPath);
return next;
});
}
}
},
[volumeName, fetchedFolders, queryClient.ensureQueryData],
);
const handleFolderHover = useCallback(
(folderPath: string) => {
if (!fetchedFolders.has(folderPath) && !loadingFolders.has(folderPath)) {
prefetchFolder: (path) => {
queryClient.prefetchQuery(
listFilesOptions({
path: { name: volumeName },
query: { path: folderPath },
query: { path },
}),
);
}
},
[volumeName, fetchedFolders, loadingFolders, queryClient],
);
});
if (isLoading && fileArray.length === 0) {
if (fileBrowser.isLoading) {
return (
<div className="flex items-center justify-center h-full min-h-[200px]">
<p className="text-muted-foreground">Loading files...</p>
@@ -134,7 +71,7 @@ export const VolumeFileBrowser = ({
);
}
if (fileArray.length === 0) {
if (fileBrowser.isEmpty) {
return (
<div className="flex flex-col items-center justify-center h-full text-center p-8 min-h-[200px]">
<FolderOpen className="mb-4 h-12 w-12 text-muted-foreground" />
@@ -147,11 +84,11 @@ export const VolumeFileBrowser = ({
return (
<div className={className}>
<FileTree
files={fileArray}
onFolderExpand={handleFolderExpand}
onFolderHover={handleFolderHover}
expandedFolders={expandedFolders}
loadingFolders={loadingFolders}
files={fileBrowser.fileArray}
onFolderExpand={fileBrowser.handleFolderExpand}
onFolderHover={fileBrowser.handleFolderHover}
expandedFolders={fileBrowser.expandedFolders}
loadingFolders={fileBrowser.loadingFolders}
withCheckboxes={withCheckboxes}
selectedPaths={selectedPaths}
onSelectionChange={onSelectionChange}

View File

@@ -0,0 +1,135 @@
import { useCallback, useMemo, useState } from "react";
import type { FileEntry } from "../components/file-tree";
type FetchFolderFn = (
path: string,
) => Promise<{ files?: FileEntry[]; directories?: Array<{ name: string; path: string }> }>;
type PathTransformFns = {
strip?: (path: string) => string;
add?: (path: string) => string;
};
type UseFileBrowserOptions = {
initialData?: { files?: FileEntry[]; directories?: Array<{ name: string; path: string }> };
isLoading?: boolean;
fetchFolder: FetchFolderFn;
prefetchFolder?: (path: string) => void;
pathTransform?: PathTransformFns;
rootPath?: string;
};
export const useFileBrowser = (props: UseFileBrowserOptions) => {
const { initialData, isLoading, fetchFolder, prefetchFolder, pathTransform, rootPath = "/" } = props;
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
const [fetchedFolders, setFetchedFolders] = useState<Set<string>>(new Set([rootPath]));
const [loadingFolders, setLoadingFolders] = useState<Set<string>>(new Set());
const [allFiles, setAllFiles] = useState<Map<string, FileEntry>>(new Map());
const stripPath = pathTransform?.strip;
const addPath = pathTransform?.add;
useMemo(() => {
if (initialData?.files) {
const files = initialData.files;
setAllFiles((prev) => {
const next = new Map(prev);
for (const file of files) {
const path = stripPath ? stripPath(file.path) : file.path;
if (path !== rootPath) {
next.set(path, { ...file, path });
}
}
return next;
});
if (rootPath) {
setFetchedFolders((prev) => new Set(prev).add(rootPath));
}
} else if (initialData?.directories) {
const directories = initialData.directories;
setAllFiles((prev) => {
const next = new Map(prev);
for (const dir of directories) {
next.set(dir.path, { name: dir.name, path: dir.path, type: "folder" });
}
return next;
});
}
}, [initialData, stripPath, rootPath]);
const fileArray = useMemo(() => Array.from(allFiles.values()), [allFiles]);
const handleFolderExpand = useCallback(
async (folderPath: string) => {
setExpandedFolders((prev) => {
const next = new Set(prev);
next.add(folderPath);
return next;
});
if (!fetchedFolders.has(folderPath)) {
setLoadingFolders((prev) => new Set(prev).add(folderPath));
try {
const pathToFetch = addPath ? addPath(folderPath) : folderPath;
const result = await fetchFolder(pathToFetch);
if (result.files) {
const files = result.files;
setAllFiles((prev) => {
const next = new Map(prev);
for (const file of files) {
const strippedPath = stripPath ? stripPath(file.path) : file.path;
// Skip the directory itself
if (strippedPath !== folderPath) {
next.set(strippedPath, { ...file, path: strippedPath });
}
}
return next;
});
} else if (result.directories) {
const directories = result.directories;
setAllFiles((prev) => {
const next = new Map(prev);
for (const dir of directories) {
next.set(dir.path, { name: dir.name, path: dir.path, type: "folder" });
}
return next;
});
}
setFetchedFolders((prev) => new Set(prev).add(folderPath));
} catch (error) {
console.error("Failed to fetch folder contents:", error);
} finally {
setLoadingFolders((prev) => {
const next = new Set(prev);
next.delete(folderPath);
return next;
});
}
}
},
[fetchedFolders, fetchFolder, stripPath, addPath],
);
const handleFolderHover = useCallback(
(folderPath: string) => {
if (!fetchedFolders.has(folderPath) && !loadingFolders.has(folderPath) && prefetchFolder) {
const pathToPrefetch = addPath ? addPath(folderPath) : folderPath;
prefetchFolder(pathToPrefetch);
}
},
[fetchedFolders, loadingFolders, prefetchFolder, addPath],
);
return {
fileArray,
expandedFolders,
loadingFolders,
handleFolderExpand,
handleFolderHover,
isLoading: isLoading && fileArray.length === 0,
isEmpty: fileArray.length === 0 && !isLoading,
};
};

View File

@@ -1,86 +0,0 @@
import { useLocation, useParams } from "react-router";
export interface BreadcrumbItem {
label: string;
href?: string;
isCurrentPage?: boolean;
}
/**
* Generates breadcrumb items based on the current route
* @param pathname - Current pathname from useLocation
* @param params - Route parameters from useParams
* @returns Array of breadcrumb items
*/
export function generateBreadcrumbs(pathname: string, params: Record<string, string | undefined>): BreadcrumbItem[] {
const breadcrumbs: BreadcrumbItem[] = [];
if (pathname.startsWith("/repositories")) {
breadcrumbs.push({
label: "Repositories",
href: "/repositories",
isCurrentPage: pathname === "/repositories",
});
if (pathname.startsWith("/repositories/") && params.name) {
const isSnapshotPage = !!params.snapshotId;
breadcrumbs.push({
label: params.name,
href: isSnapshotPage ? `/repositories/${params.name}` : undefined,
isCurrentPage: !isSnapshotPage,
});
if (isSnapshotPage && params.snapshotId) {
breadcrumbs.push({
label: params.snapshotId,
isCurrentPage: true,
});
}
}
return breadcrumbs;
}
if (pathname.startsWith("/backups")) {
breadcrumbs.push({
label: "Backups",
href: "/backups",
isCurrentPage: pathname === "/backups",
});
if (pathname.startsWith("/backups/") && params.id) {
breadcrumbs.push({
label: `Schedule #${params.id}`,
isCurrentPage: true,
});
}
return breadcrumbs;
}
breadcrumbs.push({
label: "Volumes",
href: "/volumes",
isCurrentPage: pathname === "/volumes",
});
if (pathname.startsWith("/volumes/") && params.name) {
breadcrumbs.push({
label: params.name,
isCurrentPage: true,
});
}
return breadcrumbs;
}
/**
* Hook to get breadcrumb data for the current route
*/
export function useBreadcrumbs(): BreadcrumbItem[] {
const location = useLocation();
const params = useParams();
return generateBreadcrumbs(location.pathname, params);
}

View File

@@ -0,0 +1 @@
export const APP_VERSION = import.meta.env.VITE_APP_VERSION || "dev";

View File

@@ -16,7 +16,7 @@ export const clientMiddleware = [authMiddleware];
export function meta(_: Route.MetaArgs) {
return [
{ title: "Download Recovery Key" },
{ title: "Ironmount - Download Recovery Key" },
{
name: "description",
content: "Download your backup recovery key to ensure you can restore your data.",

View File

@@ -16,7 +16,7 @@ export const clientMiddleware = [authMiddleware];
export function meta(_: Route.MetaArgs) {
return [
{ title: "Login" },
{ title: "Ironmount - Login" },
{
name: "description",
content: "Sign in to your Ironmount account.",

View File

@@ -24,7 +24,7 @@ export const clientMiddleware = [authMiddleware];
export function meta(_: Route.MetaArgs) {
return [
{ title: "Onboarding" },
{ title: "Ironmount - Onboarding" },
{
name: "description",
content: "Welcome to Ironmount. Create your admin account to get started.",

View File

@@ -254,8 +254,8 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
<CardHeader>
<CardTitle>Backup paths</CardTitle>
<CardDescription>
Select which folders to include in the backup. If no paths are selected, the entire volume will be
backed up.
Select which folders or files to include in the backup. If no paths are selected, the entire volume will
be backed up.
</CardDescription>
</CardHeader>
<CardContent>
@@ -264,7 +264,7 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
selectedPaths={selectedPaths}
onSelectionChange={handleSelectionChange}
withCheckboxes={true}
foldersOnly={true}
foldersOnly={false}
className="flex-1 border rounded-md bg-card p-2 min-h-[300px] max-h-[400px] overflow-auto"
/>
{selectedPaths.size > 0 && (

View File

@@ -1,4 +1,4 @@
import { Pencil, Play, Square, Trash2 } from "lucide-react";
import { Eraser, Pencil, Play, Square, Trash2 } from "lucide-react";
import { useMemo, useState } from "react";
import { OnOff } from "~/client/components/onoff";
import { Button } from "~/client/components/ui/button";
@@ -14,6 +14,10 @@ import {
} from "~/client/components/ui/alert-dialog";
import type { BackupSchedule } from "~/client/lib/types";
import { BackupProgressCard } from "./backup-progress-card";
import { runForgetMutation } from "~/client/api-client/@tanstack/react-query.gen";
import { useMutation } from "@tanstack/react-query";
import { toast } from "sonner";
import { parseError } from "~/client/lib/errors";
type Props = {
schedule: BackupSchedule;
@@ -28,6 +32,17 @@ export const ScheduleSummary = (props: Props) => {
const { schedule, handleToggleEnabled, handleRunBackupNow, handleStopBackup, handleDeleteSchedule, setIsEditMode } =
props;
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [showForgetConfirm, setShowForgetConfirm] = useState(false);
const runForget = useMutation({
...runForgetMutation(),
onSuccess: () => {
toast.success("Retention policy applied successfully");
},
onError: (error) => {
toast.error("Failed to apply retention policy", { description: parseError(error)?.message });
},
});
const summary = useMemo(() => {
const scheduleLabel = schedule ? schedule.cronExpression : "-";
@@ -56,6 +71,11 @@ export const ScheduleSummary = (props: Props) => {
handleDeleteSchedule();
};
const handleConfirmForget = () => {
setShowForgetConfirm(false);
runForget.mutate({ path: { scheduleId: schedule.id.toString() } });
};
return (
<div className="space-y-4">
<Card>
@@ -89,6 +109,18 @@ export const ScheduleSummary = (props: Props) => {
<span className="sm:inline">Backup now</span>
</Button>
)}
{schedule.retentionPolicy && (
<Button
variant="outline"
size="sm"
loading={runForget.isPending}
onClick={() => setShowForgetConfirm(true)}
className="w-full sm:w-auto"
>
<Eraser className="h-4 w-4 mr-2" />
<span className="sm:inline">Run cleanup</span>
</Button>
)}
<Button variant="outline" size="sm" onClick={() => setIsEditMode(true)} className="w-full sm:w-auto">
<Pencil className="h-4 w-4 mr-2" />
<span className="sm:inline">Edit schedule</span>
@@ -167,6 +199,22 @@ export const ScheduleSummary = (props: Props) => {
</div>
</AlertDialogContent>
</AlertDialog>
<AlertDialog open={showForgetConfirm} onOpenChange={setShowForgetConfirm}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Run retention policy cleanup?</AlertDialogTitle>
<AlertDialogDescription>
This will apply the retention policy and permanently delete old snapshots according to the configured
rules ({summary.retentionLabel}). This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="flex gap-3 justify-end">
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleConfirmForget}>Run cleanup</AlertDialogAction>
</div>
</AlertDialogContent>
</AlertDialog>
</div>
);
};

View File

@@ -1,7 +1,7 @@
import { useCallback, useMemo, useState } from "react";
import { useCallback, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { FileIcon } from "lucide-react";
import { FileTree, type FileEntry } from "~/client/components/file-tree";
import { FileTree } from "~/client/components/file-tree";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
import { Button } from "~/client/components/ui/button";
import { Checkbox } from "~/client/components/ui/checkbox";
@@ -20,23 +20,22 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "~/client/components/ui/
import type { Snapshot, Volume } from "~/client/lib/types";
import { toast } from "sonner";
import { listSnapshotFilesOptions, restoreSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen";
import { useFileBrowser } from "~/client/hooks/use-file-browser";
interface Props {
snapshot: Snapshot;
repositoryName: string;
volume?: Volume;
onDeleteSnapshot?: (snapshotId: string) => void;
isDeletingSnapshot?: boolean;
}
export const SnapshotFileBrowser = (props: Props) => {
const { snapshot, repositoryName, volume } = props;
const { snapshot, repositoryName, volume, onDeleteSnapshot, isDeletingSnapshot } = props;
const isReadOnly = volume?.config && "readOnly" in volume.config && volume.config.readOnly === true;
const queryClient = useQueryClient();
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
const [fetchedFolders, setFetchedFolders] = useState<Set<string>>(new Set());
const [loadingFolders, setLoadingFolders] = useState<Set<string>>(new Set());
const [allFiles, setAllFiles] = useState<Map<string, FileEntry>>(new Map());
const [selectedPaths, setSelectedPaths] = useState<Set<string>>(new Set());
const [showRestoreDialog, setShowRestoreDialog] = useState(false);
const [deleteExtraFiles, setDeleteExtraFiles] = useState(false);
@@ -72,89 +71,30 @@ export const SnapshotFileBrowser = (props: Props) => {
[volumeBasePath],
);
useMemo(() => {
if (filesData?.files) {
setAllFiles((prev) => {
const next = new Map(prev);
for (const file of filesData.files) {
const strippedPath = stripBasePath(file.path);
if (strippedPath !== "/") {
next.set(strippedPath, { ...file, path: strippedPath });
}
}
return next;
});
setFetchedFolders((prev) => new Set(prev).add("/"));
}
}, [filesData, stripBasePath]);
const fileArray = useMemo(() => Array.from(allFiles.values()), [allFiles]);
const handleFolderExpand = useCallback(
async (folderPath: string) => {
setExpandedFolders((prev) => {
const next = new Set(prev);
next.add(folderPath);
return next;
});
if (!fetchedFolders.has(folderPath)) {
setLoadingFolders((prev) => new Set(prev).add(folderPath));
try {
const fullPath = addBasePath(folderPath);
const result = await queryClient.ensureQueryData(
const fileBrowser = useFileBrowser({
initialData: filesData,
isLoading: filesLoading,
fetchFolder: async (path) => {
return await queryClient.ensureQueryData(
listSnapshotFilesOptions({
path: { name: repositoryName, snapshotId: snapshot.short_id },
query: { path: fullPath },
query: { path },
}),
);
if (result.files) {
setAllFiles((prev) => {
const next = new Map(prev);
for (const file of result.files) {
const strippedPath = stripBasePath(file.path);
// Skip the directory itself
if (strippedPath !== folderPath) {
next.set(strippedPath, { ...file, path: strippedPath });
}
}
return next;
});
setFetchedFolders((prev) => new Set(prev).add(folderPath));
}
} catch (error) {
console.error("Failed to fetch folder contents:", error);
} finally {
setLoadingFolders((prev) => {
const next = new Set(prev);
next.delete(folderPath);
return next;
});
}
}
},
[repositoryName, snapshot, fetchedFolders, queryClient, stripBasePath, addBasePath],
);
const handleFolderHover = useCallback(
(folderPath: string) => {
if (!fetchedFolders.has(folderPath) && !loadingFolders.has(folderPath)) {
const fullPath = addBasePath(folderPath);
queryClient.prefetchQuery({
...listSnapshotFilesOptions({
prefetchFolder: (path) => {
queryClient.prefetchQuery(
listSnapshotFilesOptions({
path: { name: repositoryName, snapshotId: snapshot.short_id },
query: { path: fullPath },
query: { path },
}),
});
}
},
[repositoryName, snapshot, fetchedFolders, loadingFolders, queryClient, addBasePath],
);
},
pathTransform: {
strip: stripBasePath,
add: addBasePath,
},
});
const { mutate: restoreSnapshot, isPending: isRestoring } = useMutation({
...restoreSnapshotMutation(),
@@ -198,6 +138,7 @@ export const SnapshotFileBrowser = (props: Props) => {
<CardTitle>File Browser</CardTitle>
<CardDescription>{`Viewing snapshot from ${new Date(snapshot?.time ?? 0).toLocaleString()}`}</CardDescription>
</div>
<div className="flex gap-2">
{selectedPaths.size > 0 && (
<Tooltip>
<TooltipTrigger asChild>
@@ -222,30 +163,42 @@ export const SnapshotFileBrowser = (props: Props) => {
)}
</Tooltip>
)}
{onDeleteSnapshot && (
<Button
variant="destructive"
size="sm"
onClick={() => onDeleteSnapshot(snapshot.short_id)}
disabled={isDeletingSnapshot}
loading={isDeletingSnapshot}
>
{isDeletingSnapshot ? "Deleting..." : "Delete Snapshot"}
</Button>
)}
</div>
</div>
</CardHeader>
<CardContent className="flex-1 overflow-hidden flex flex-col p-0">
{filesLoading && fileArray.length === 0 && (
{fileBrowser.isLoading && (
<div className="flex items-center justify-center flex-1">
<p className="text-muted-foreground">Loading files...</p>
</div>
)}
{fileArray.length === 0 && !filesLoading && (
{fileBrowser.isEmpty && (
<div className="flex flex-col items-center justify-center flex-1 text-center p-8">
<FileIcon className="w-12 h-12 text-muted-foreground/50 mb-4" />
<p className="text-muted-foreground">No files in this snapshot</p>
</div>
)}
{fileArray.length > 0 && (
{!fileBrowser.isLoading && !fileBrowser.isEmpty && (
<div className="overflow-auto flex-1 border border-border rounded-md bg-card m-4">
<FileTree
files={fileArray}
onFolderExpand={handleFolderExpand}
onFolderHover={handleFolderHover}
expandedFolders={expandedFolders}
loadingFolders={loadingFolders}
files={fileBrowser.fileArray}
onFolderExpand={fileBrowser.handleFolderExpand}
onFolderHover={fileBrowser.handleFolderHover}
expandedFolders={fileBrowser.expandedFolders}
loadingFolders={fileBrowser.loadingFolders}
className="px-2 py-2"
withCheckboxes={true}
selectedPaths={selectedPaths}

View File

@@ -3,6 +3,16 @@ import { useQuery, useMutation } from "@tanstack/react-query";
import { redirect, useNavigate } from "react-router";
import { toast } from "sonner";
import { Button } from "~/client/components/ui/button";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "~/client/components/ui/alert-dialog";
import {
getBackupScheduleOptions,
runBackupNowMutation,
@@ -10,6 +20,7 @@ import {
listSnapshotsOptions,
updateBackupScheduleMutation,
stopBackupMutation,
deleteSnapshotMutation,
} from "~/client/api-client/@tanstack/react-query.gen";
import { parseError } from "~/client/lib/errors";
import { getCronExpression } from "~/utils/utils";
@@ -20,9 +31,16 @@ import { SnapshotFileBrowser } from "../components/snapshot-file-browser";
import { SnapshotTimeline } from "../components/snapshot-timeline";
import { getBackupSchedule } from "~/client/api-client";
export const handle = {
breadcrumb: (match: Route.MetaArgs) => [
{ label: "Backups", href: "/backups" },
{ label: `Schedule #${match.params.id}` },
],
};
export function meta(_: Route.MetaArgs) {
return [
{ title: "Backup Job Details" },
{ title: "Ironmount - Backup Job Details" },
{
name: "description",
content: "View and manage backup job configuration, schedule, and snapshots.",
@@ -43,6 +61,8 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
const [isEditMode, setIsEditMode] = useState(false);
const formId = useId();
const [selectedSnapshotId, setSelectedSnapshotId] = useState<string>();
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [snapshotToDelete, setSnapshotToDelete] = useState<string | null>(null);
const { data: schedule } = useQuery({
...getBackupScheduleOptions({ path: { scheduleId: params.id } }),
@@ -103,6 +123,17 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
},
});
const deleteSnapshot = useMutation({
...deleteSnapshotMutation(),
onSuccess: () => {
setShowDeleteConfirm(false);
setSnapshotToDelete(null);
if (selectedSnapshotId === snapshotToDelete) {
setSelectedSnapshotId(undefined);
}
},
});
const handleSubmit = (formValues: BackupScheduleFormValues) => {
if (!schedule) return;
@@ -143,6 +174,26 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
});
};
const handleDeleteSnapshot = (snapshotId: string) => {
setSnapshotToDelete(snapshotId);
setShowDeleteConfirm(true);
};
const handleConfirmDelete = () => {
if (snapshotToDelete) {
toast.promise(
deleteSnapshot.mutateAsync({
path: { name: schedule.repository.name, snapshotId: snapshotToDelete },
}),
{
loading: "Deleting snapshot...",
success: "Snapshot deleted successfully",
error: (error) => parseError(error)?.message || "Failed to delete snapshot",
},
);
}
};
if (isEditMode) {
return (
<div>
@@ -184,8 +235,32 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
snapshot={selectedSnapshot}
repositoryName={schedule.repository.name}
volume={schedule.volume}
onDeleteSnapshot={handleDeleteSnapshot}
isDeletingSnapshot={deleteSnapshot.isPending}
/>
)}
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete snapshot?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete the snapshot and all its data from the
repository.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmDelete}
disabled={deleteSnapshot.isPending}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete snapshot
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@@ -9,9 +9,13 @@ import type { Route } from "./+types/backups";
import { listBackupSchedules } from "~/client/api-client";
import { listBackupSchedulesOptions } from "~/client/api-client/@tanstack/react-query.gen";
export const handle = {
breadcrumb: () => [{ label: "Backups" }],
};
export function meta(_: Route.MetaArgs) {
return [
{ title: "Backup Jobs" },
{ title: "Ironmount - Backup Jobs" },
{
name: "description",
content: "Automate volume backups with scheduled jobs and retention policies.",

View File

@@ -18,9 +18,13 @@ import { CreateScheduleForm, type BackupScheduleFormValues } from "../components
import type { Route } from "./+types/create-backup";
import { listRepositories, listVolumes } from "~/client/api-client";
export const handle = {
breadcrumb: () => [{ label: "Backups", href: "/backups" }, { label: "Create" }],
};
export function meta(_: Route.MetaArgs) {
return [
{ title: "Create Backup Job" },
{ title: "Ironmount - Create Backup Job" },
{
name: "description",
content: "Create a new automated backup job for your volumes.",

View File

@@ -0,0 +1,89 @@
import { useMutation } from "@tanstack/react-query";
import { Database } from "lucide-react";
import { useId } from "react";
import { useNavigate } from "react-router";
import { toast } from "sonner";
import { createRepositoryMutation } from "~/client/api-client/@tanstack/react-query.gen";
import { CreateRepositoryForm, type RepositoryFormValues } from "~/client/components/create-repository-form";
import { Button } from "~/client/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
import { parseError } from "~/client/lib/errors";
import type { Route } from "./+types/create-repository";
import { Alert, AlertDescription } from "~/client/components/ui/alert";
export const handle = {
breadcrumb: () => [{ label: "Repositories", href: "/repositories" }, { label: "Create" }],
};
export function meta(_: Route.MetaArgs) {
return [
{ title: "Ironmount - Create Repository" },
{
name: "description",
content: "Create a new backup repository with encryption and compression.",
},
];
}
export default function CreateRepository() {
const navigate = useNavigate();
const formId = useId();
const createRepository = useMutation({
...createRepositoryMutation(),
onSuccess: (data) => {
toast.success("Repository created successfully");
navigate(`/repositories/${data.repository.name}`);
},
});
const handleSubmit = (values: RepositoryFormValues) => {
createRepository.mutate({
body: {
config: values,
name: values.name,
compressionMode: values.compressionMode,
},
});
};
return (
<div className="container mx-auto space-y-6">
<Card>
<CardHeader>
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-10 h-10 rounded-lg bg-primary/10">
<Database className="w-5 h-5 text-primary" />
</div>
<CardTitle>Create Repository</CardTitle>
</div>
</CardHeader>
<CardContent className="space-y-6">
{createRepository.isError && (
<Alert variant="destructive">
<AlertDescription>
<strong>Failed to create repository:</strong>
<br />
{parseError(createRepository.error)?.message}
</AlertDescription>
</Alert>
)}
<CreateRepositoryForm
mode="create"
formId={formId}
onSubmit={handleSubmit}
loading={createRepository.isPending}
/>
<div className="flex justify-end gap-2 pt-4 border-t">
<Button type="button" variant="secondary" onClick={() => navigate("/repositories")}>
Cancel
</Button>
<Button type="submit" form={formId} loading={createRepository.isPending}>
Create Repository
</Button>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -1,10 +1,9 @@
import { useQuery } from "@tanstack/react-query";
import { Database, RotateCcw } from "lucide-react";
import { Database, Plus, RotateCcw } from "lucide-react";
import { useState } from "react";
import { useNavigate } from "react-router";
import { listRepositories } from "~/client/api-client/sdk.gen";
import { listRepositoriesOptions } from "~/client/api-client/@tanstack/react-query.gen";
import { CreateRepositoryDialog } from "~/client/components/create-repository-dialog";
import { RepositoryIcon } from "~/client/components/repository-icon";
import { Button } from "~/client/components/ui/button";
import { Card } from "~/client/components/ui/card";
@@ -15,9 +14,13 @@ import type { Route } from "./+types/repositories";
import { cn } from "~/client/lib/utils";
import { EmptyState } from "~/client/components/empty-state";
export const handle = {
breadcrumb: () => [{ label: "Repositories" }],
};
export function meta(_: Route.MetaArgs) {
return [
{ title: "Repositories" },
{ title: "Ironmount - Repositories" },
{
name: "description",
content: "Manage your backup repositories with encryption and compression.",
@@ -35,7 +38,6 @@ export default function Repositories({ loaderData }: Route.ComponentProps) {
const [searchQuery, setSearchQuery] = useState("");
const [statusFilter, setStatusFilter] = useState("");
const [backendFilter, setBackendFilter] = useState("");
const [createRepositoryOpen, setCreateRepositoryOpen] = useState(false);
const clearFilters = () => {
setSearchQuery("");
@@ -69,7 +71,12 @@ export default function Repositories({ loaderData }: Route.ComponentProps) {
icon={Database}
title="No repository"
description="Repositories are remote storage locations where you can backup your volumes securely. Encrypted and optimized for storage efficiency."
button={<CreateRepositoryDialog open={createRepositoryOpen} setOpen={setCreateRepositoryOpen} />}
button={
<Button onClick={() => navigate("/repositories/create")}>
<Plus size={16} className="mr-2" />
Create Repository
</Button>
}
/>
);
}
@@ -112,7 +119,10 @@ export default function Repositories({ loaderData }: Route.ComponentProps) {
</Button>
)}
</span>
<CreateRepositoryDialog open={createRepositoryOpen} setOpen={setCreateRepositoryOpen} />
<Button onClick={() => navigate("/repositories/create")}>
<Plus size={16} className="mr-2" />
Create Repository
</Button>
</div>
<div className="overflow-x-auto">
<Table className="border-t">

View File

@@ -27,9 +27,16 @@ import { RepositoryInfoTabContent } from "../tabs/info";
import { RepositorySnapshotsTabContent } from "../tabs/snapshots";
import { Loader2 } from "lucide-react";
export const handle = {
breadcrumb: (match: Route.MetaArgs) => [
{ label: "Repositories", href: "/repositories" },
{ label: match.params.name },
],
};
export function meta({ params }: Route.MetaArgs) {
return [
{ title: params.name },
{ title: `Ironmount - ${params.name}` },
{
name: "description",
content: "View repository configuration, status, and snapshots.",

View File

@@ -7,9 +7,17 @@ import { SnapshotFileBrowser } from "~/client/modules/backups/components/snapsho
import { getSnapshotDetails } from "~/client/api-client";
import type { Route } from "./+types/snapshot-details";
export const handle = {
breadcrumb: (match: Route.MetaArgs) => [
{ label: "Repositories", href: "/repositories" },
{ label: match.params.name, href: `/repositories/${match.params.name}` },
{ label: match.params.snapshotId },
],
};
export function meta({ params }: Route.MetaArgs) {
return [
{ title: `Snapshot ${params.snapshotId}` },
{ title: `Ironmount - Snapshot ${params.snapshotId}` },
{
name: "description",
content: "Browse and restore files from a backup snapshot.",

View File

@@ -24,9 +24,13 @@ import {
logoutMutation,
} from "~/client/api-client/@tanstack/react-query.gen";
export const handle = {
breadcrumb: () => [{ label: "Settings" }],
};
export function meta(_: Route.MetaArgs) {
return [
{ title: "Settings" },
{ title: "Ironmount - Settings" },
{
name: "description",
content: "Manage your account settings and preferences.",

View File

@@ -0,0 +1,83 @@
import { useMutation } from "@tanstack/react-query";
import { HardDrive } from "lucide-react";
import { useId } from "react";
import { useNavigate } from "react-router";
import { toast } from "sonner";
import { createVolumeMutation } from "~/client/api-client/@tanstack/react-query.gen";
import { CreateVolumeForm, type FormValues } from "~/client/components/create-volume-form";
import { Button } from "~/client/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
import { parseError } from "~/client/lib/errors";
import type { Route } from "./+types/create-volume";
import { Alert, AlertDescription } from "~/client/components/ui/alert";
export const handle = {
breadcrumb: () => [{ label: "Volumes", href: "/volumes" }, { label: "Create" }],
};
export function meta(_: Route.MetaArgs) {
return [
{ title: "Ironmount - Create Volume" },
{
name: "description",
content: "Create a new storage volume with automatic mounting and health checks.",
},
];
}
export default function CreateVolume() {
const navigate = useNavigate();
const formId = useId();
const createVolume = useMutation({
...createVolumeMutation(),
onSuccess: (data) => {
toast.success("Volume created successfully");
navigate(`/volumes/${data.name}`);
},
});
const handleSubmit = (values: FormValues) => {
createVolume.mutate({
body: {
config: values,
name: values.name,
},
});
};
return (
<div className="container mx-auto space-y-6">
<Card>
<CardHeader>
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-10 h-10 rounded-lg bg-primary/10">
<HardDrive className="w-5 h-5 text-primary" />
</div>
<CardTitle>Create Volume</CardTitle>
</div>
</CardHeader>
<CardContent className="space-y-6">
{createVolume.isError && (
<Alert variant="destructive">
<AlertDescription>
<strong>Failed to create volume:</strong>
<br />
{parseError(createVolume.error)?.message}
</AlertDescription>
</Alert>
)}
<CreateVolumeForm mode="create" formId={formId} onSubmit={handleSubmit} loading={createVolume.isPending} />
<div className="flex justify-end gap-2 pt-4 border-t">
<Button type="button" variant="secondary" onClick={() => navigate("/volumes")}>
Cancel
</Button>
<Button type="submit" form={formId} loading={createVolume.isPending}>
Create Volume
</Button>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -31,9 +31,13 @@ import {
unmountVolumeMutation,
} from "~/client/api-client/@tanstack/react-query.gen";
export const handle = {
breadcrumb: (match: Route.MetaArgs) => [{ label: "Volumes", href: "/volumes" }, { label: match.params.name }],
};
export function meta({ params }: Route.MetaArgs) {
return [
{ title: params.name },
{ title: `Ironmount - ${params.name}` },
{
name: "description",
content: "View and manage volume details, configuration, and files.",

View File

@@ -1,8 +1,7 @@
import { useQuery } from "@tanstack/react-query";
import { HardDrive, RotateCcw } from "lucide-react";
import { HardDrive, Plus, RotateCcw } from "lucide-react";
import { useState } from "react";
import { useNavigate } from "react-router";
import { CreateVolumeDialog } from "~/client/components/create-volume-dialog";
import { EmptyState } from "~/client/components/empty-state";
import { StatusDot } from "~/client/components/status-dot";
import { Button } from "~/client/components/ui/button";
@@ -15,9 +14,13 @@ import type { Route } from "./+types/volumes";
import { listVolumes } from "~/client/api-client";
import { listVolumesOptions } from "~/client/api-client/@tanstack/react-query.gen";
export const handle = {
breadcrumb: () => [{ label: "Volumes" }],
};
export function meta(_: Route.MetaArgs) {
return [
{ title: "Volumes" },
{ title: "Ironmount - Volumes" },
{
name: "description",
content: "Create, manage, monitor, and automate your Docker volumes with ease.",
@@ -32,7 +35,6 @@ export const clientLoader = async () => {
};
export default function Volumes({ loaderData }: Route.ComponentProps) {
const [createVolumeOpen, setCreateVolumeOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [statusFilter, setStatusFilter] = useState("");
const [backendFilter, setBackendFilter] = useState("");
@@ -69,7 +71,12 @@ export default function Volumes({ loaderData }: Route.ComponentProps) {
icon={HardDrive}
title="No volume"
description="Manage and monitor all your storage backends in one place with advanced features like automatic mounting and health checks."
button={<CreateVolumeDialog open={createVolumeOpen} setOpen={setCreateVolumeOpen} />}
button={
<Button onClick={() => navigate("/volumes/create")}>
<Plus size={16} className="mr-2" />
Create Volume
</Button>
}
/>
);
}
@@ -111,7 +118,10 @@ export default function Volumes({ loaderData }: Route.ComponentProps) {
</Button>
)}
</span>
<CreateVolumeDialog open={createVolumeOpen} setOpen={setCreateVolumeOpen} />
<Button onClick={() => navigate("/volumes/create")}>
<Plus size={16} className="mr-2" />
Create Volume
</Button>
</div>
<div className="overflow-x-auto">
<Table className="border-t">

View File

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

View File

@@ -7,11 +7,13 @@ export default [
layout("./client/components/layout.tsx", [
route("/", "./client/routes/root.tsx"),
route("volumes", "./client/modules/volumes/routes/volumes.tsx"),
route("volumes/create", "./client/modules/volumes/routes/create-volume.tsx"),
route("volumes/:name", "./client/modules/volumes/routes/volume-details.tsx"),
route("backups", "./client/modules/backups/routes/backups.tsx"),
route("backups/create", "./client/modules/backups/routes/create-backup.tsx"),
route("backups/:id", "./client/modules/backups/routes/backup-details.tsx"),
route("repositories", "./client/modules/repositories/routes/repositories.tsx"),
route("repositories/create", "./client/modules/repositories/routes/create-repository.tsx"),
route("repositories/:name", "./client/modules/repositories/routes/repository-details.tsx"),
route("repositories/:name/:snapshotId", "./client/modules/repositories/routes/snapshot-details.tsx"),
route("settings", "./client/modules/settings/routes/settings.tsx"),

1
app/schemas/node_modules/arktype generated vendored
View File

@@ -1 +0,0 @@
../../../node_modules/.bun/arktype@2.1.26/node_modules/arktype

View File

@@ -3,6 +3,7 @@ import { type } from "arktype";
export const REPOSITORY_BACKENDS = {
local: "local",
s3: "s3",
r2: "r2",
gcs: "gcs",
azure: "azure",
rclone: "rclone",
@@ -10,25 +11,39 @@ export const REPOSITORY_BACKENDS = {
export type RepositoryBackend = keyof typeof REPOSITORY_BACKENDS;
// Common fields for all repository configs
const baseRepositoryConfigSchema = type({
isExistingRepository: "boolean?",
customPassword: "string?",
});
export const s3RepositoryConfigSchema = type({
backend: "'s3'",
endpoint: "string",
bucket: "string",
accessKeyId: "string",
secretAccessKey: "string",
});
}).and(baseRepositoryConfigSchema);
export const r2RepositoryConfigSchema = type({
backend: "'r2'",
endpoint: "string",
bucket: "string",
accessKeyId: "string",
secretAccessKey: "string",
}).and(baseRepositoryConfigSchema);
export const localRepositoryConfigSchema = type({
backend: "'local'",
name: "string",
});
}).and(baseRepositoryConfigSchema);
export const gcsRepositoryConfigSchema = type({
backend: "'gcs'",
bucket: "string",
projectId: "string",
credentialsJson: "string",
});
}).and(baseRepositoryConfigSchema);
export const azureRepositoryConfigSchema = type({
backend: "'azure'",
@@ -36,15 +51,16 @@ export const azureRepositoryConfigSchema = type({
accountName: "string",
accountKey: "string",
endpointSuffix: "string?",
});
}).and(baseRepositoryConfigSchema);
export const rcloneRepositoryConfigSchema = type({
backend: "'rclone'",
remote: "string",
path: "string",
});
}).and(baseRepositoryConfigSchema);
export const repositoryConfigSchema = s3RepositoryConfigSchema
.or(r2RepositoryConfigSchema)
.or(localRepositoryConfigSchema)
.or(gcsRepositoryConfigSchema)
.or(azureRepositoryConfigSchema)

View File

@@ -39,6 +39,14 @@ class SchedulerClass {
this.tasks = [];
logger.info("Scheduler stopped");
}
async clear() {
for (const task of this.tasks) {
task.destroy();
}
this.tasks = [];
logger.info("Scheduler cleared all tasks");
}
}
export const Scheduler = new SchedulerClass();

View File

@@ -19,7 +19,9 @@ export class CleanupDanglingMountsJob extends Job {
const matchingVolume = allVolumes.find((v) => getVolumePath(v) === mount.mountPoint);
if (!matchingVolume) {
logger.info(`Found dangling mount at ${mount.mountPoint}, attempting to unmount...`);
await executeUnmount(mount.mountPoint);
await executeUnmount(mount.mountPoint).catch((err) => {
logger.warn(`Failed to unmount dangling mount ${mount.mountPoint}: ${toMessage(err)}`);
});
await fs.rmdir(path.dirname(mount.mountPoint)).catch((err) => {
logger.warn(

View File

@@ -25,7 +25,7 @@ import { toMessage } from "../../utils/errors";
const COOKIE_NAME = "session_id";
const COOKIE_OPTIONS = {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
secure: false,
sameSite: "lax" as const,
path: "/",
};

View File

@@ -13,6 +13,10 @@ export const executeMount = async (args: string[]): Promise<void> => {
if (stderr?.trim()) {
logger.warn(stderr.trim());
}
if (result.exitCode !== 0) {
throw new Error(`Mount command failed with exit code ${result.exitCode}: ${stderr?.trim()}`);
}
};
export const executeUnmount = async (path: string): Promise<void> => {
@@ -24,6 +28,10 @@ export const executeUnmount = async (path: string): Promise<void> => {
if (stderr?.trim()) {
logger.warn(stderr.trim());
}
if (result.exitCode !== 0) {
throw new Error(`Mount command failed with exit code ${result.exitCode}: ${stderr?.trim()}`);
}
};
export const createTestFile = async (path: string): Promise<void> => {

View File

@@ -8,6 +8,7 @@ import {
getBackupScheduleForVolumeDto,
listBackupSchedulesDto,
runBackupNowDto,
runForgetDto,
stopBackupDto,
updateBackupScheduleDto,
updateBackupScheduleBody,
@@ -17,6 +18,7 @@ import {
type GetBackupScheduleForVolumeResponseDto,
type ListBackupSchedulesResponseDto,
type RunBackupNowDto,
type RunForgetDto,
type StopBackupDto,
type UpdateBackupScheduleDto,
} from "./backups.dto";
@@ -78,4 +80,11 @@ export const backupScheduleController = new Hono()
await backupsService.stopBackup(Number(scheduleId));
return c.json<StopBackupDto>({ success: true }, 200);
})
.post("/:scheduleId/forget", runForgetDto, async (c) => {
const scheduleId = c.req.param("scheduleId");
await backupsService.runForget(Number(scheduleId));
return c.json<RunForgetDto>({ success: true }, 200);
});

View File

@@ -251,3 +251,28 @@ export const stopBackupDto = describeRoute({
},
},
});
/**
* Run retention policy (forget) manually
*/
export const runForgetResponse = type({
success: "boolean",
});
export type RunForgetDto = typeof runForgetResponse.infer;
export const runForgetDto = describeRoute({
description: "Manually apply retention policy to clean up old snapshots",
operationId: "runForget",
tags: ["Backups"],
responses: {
200: {
description: "Retention policy applied successfully",
content: {
"application/json": {
schema: resolver(runForgetResponse),
},
},
},
},
});

View File

@@ -17,7 +17,7 @@ const calculateNextRun = (cronExpression: string): number => {
try {
const interval = CronExpressionParser.parse(cronExpression, {
currentDate: new Date(),
tz: "UTC",
tz: Intl.DateTimeFormat().resolvedOptions().timeZone,
});
return interval.next().getTime();
@@ -195,9 +195,11 @@ const executeBackup = async (scheduleId: number, manual = false) => {
repositoryName: repository.name,
});
const nextBackupAt = calculateNextRun(schedule.cronExpression);
await db
.update(backupSchedulesTable)
.set({ lastBackupStatus: "in_progress", updatedAt: Date.now(), lastBackupError: null })
.set({ lastBackupStatus: "in_progress", updatedAt: Date.now(), lastBackupError: null, nextBackupAt })
.where(eq(backupSchedulesTable.id, scheduleId));
const abortController = new AbortController();
@@ -340,6 +342,32 @@ const stopBackup = async (scheduleId: number) => {
abortController.abort();
};
const runForget = async (scheduleId: number) => {
const schedule = await db.query.backupSchedulesTable.findFirst({
where: eq(backupSchedulesTable.id, scheduleId),
});
if (!schedule) {
throw new NotFoundError("Backup schedule not found");
}
if (!schedule.retentionPolicy) {
throw new BadRequestError("No retention policy configured for this schedule");
}
const repository = await db.query.repositoriesTable.findFirst({
where: eq(repositoriesTable.id, schedule.repositoryId),
});
if (!repository) {
throw new NotFoundError("Repository not found");
}
logger.info(`Manually running retention policy (forget) for schedule ${scheduleId}`);
await restic.forget(repository.config, schedule.retentionPolicy, { tag: schedule.id.toString() });
logger.info(`Retention policy applied successfully for schedule ${scheduleId}`);
};
export const backupsService = {
listSchedules,
getSchedule,
@@ -350,4 +378,5 @@ export const backupsService = {
getSchedulesToExecute,
getScheduleForVolume,
stopBackup,
runForget,
};

View File

@@ -13,6 +13,7 @@ import { CleanupSessionsJob } from "../../jobs/cleanup-sessions";
export const startup = async () => {
await Scheduler.start();
await Scheduler.clear();
await restic.ensurePassfile().catch((err) => {
logger.error(`Error ensuring restic passfile exists: ${err.message}`);

View File

@@ -4,6 +4,7 @@ import {
createRepositoryBody,
createRepositoryDto,
deleteRepositoryDto,
deleteSnapshotDto,
doctorRepositoryDto,
getRepositoryDto,
getSnapshotDetailsDto,
@@ -16,6 +17,7 @@ import {
restoreSnapshotBody,
restoreSnapshotDto,
type DeleteRepositoryDto,
type DeleteSnapshotDto,
type DoctorRepositoryDto,
type GetRepositoryDto,
type GetSnapshotDetailsDto,
@@ -142,4 +144,11 @@ export const repositoriesController = new Hono()
const result = await repositoriesService.doctorRepository(name);
return c.json<DoctorRepositoryDto>(result, 200);
})
.delete("/:name/snapshots/:snapshotId", deleteSnapshotDto, async (c) => {
const { name, snapshotId } = c.req.param();
await repositoriesService.deleteSnapshot(name, snapshotId);
return c.json<DeleteSnapshotDto>({ message: "Snapshot deleted" }, 200);
});

View File

@@ -326,3 +326,28 @@ export const listRcloneRemotesDto = describeRoute({
},
},
});
/**
* Delete a snapshot
*/
export const deleteSnapshotResponse = type({
message: "string",
});
export type DeleteSnapshotDto = typeof deleteSnapshotResponse.infer;
export const deleteSnapshotDto = describeRoute({
description: "Delete a specific snapshot from a repository",
tags: ["Repositories"],
operationId: "deleteSnapshot",
responses: {
200: {
description: "Snapshot deleted successfully",
content: {
"application/json": {
schema: resolver(deleteSnapshotResponse),
},
},
},
},
});

View File

@@ -15,10 +15,15 @@ const listRepositories = async () => {
};
const encryptConfig = async (config: RepositoryConfig): Promise<RepositoryConfig> => {
const encryptedConfig: Record<string, string> = { ...config };
const encryptedConfig: Record<string, string | boolean> = { ...config };
if (config.customPassword) {
encryptedConfig.customPassword = await cryptoUtils.encrypt(config.customPassword);
}
switch (config.backend) {
case "s3":
case "r2":
encryptedConfig.accessKeyId = await cryptoUtils.encrypt(config.accessKeyId);
encryptedConfig.secretAccessKey = await cryptoUtils.encrypt(config.secretAccessKey);
break;
@@ -64,16 +69,24 @@ const createRepository = async (name: string, config: RepositoryConfig, compress
throw new InternalServerError("Failed to create repository");
}
const { success, error } = await restic.init(encryptedConfig);
let error: string | null = null;
if (success) {
if (config.isExistingRepository) {
const result = await restic
.snapshots(encryptedConfig)
.then(() => ({ error: null }))
.catch((error) => ({ error }));
error = result.error;
} else {
const initResult = await restic.init(encryptedConfig);
error = initResult.error;
}
if (!error) {
await db
.update(repositoriesTable)
.set({
status: "healthy",
lastChecked: Date.now(),
lastError: null,
})
.set({ status: "healthy", lastChecked: Date.now(), lastError: null })
.where(eq(repositoriesTable.id, id));
return { repository: created, status: 201 };
@@ -314,6 +327,18 @@ const doctorRepository = async (name: string) => {
};
};
const deleteSnapshot = async (name: string, snapshotId: string) => {
const repository = await db.query.repositoriesTable.findFirst({
where: eq(repositoriesTable.name, name),
});
if (!repository) {
throw new NotFoundError("Repository not found");
}
await restic.deleteSnapshot(repository.config, snapshotId);
};
export const repositoriesService = {
listRepositories,
createRepository,
@@ -325,4 +350,5 @@ export const repositoriesService = {
getSnapshotDetails,
checkHealth,
doctorRepository,
deleteSnapshot,
};

View File

@@ -74,6 +74,10 @@ const buildRepoUrl = (config: RepositoryConfig): string => {
return `${REPOSITORY_BASE}/${config.name}`;
case "s3":
return `s3:${config.endpoint}/${config.bucket}`;
case "r2": {
const endpoint = config.endpoint.replace(/^https?:\/\//, "");
return `s3:${endpoint}/${config.bucket}`;
}
case "gcs":
return `gs:${config.bucket}:/`;
case "azure":
@@ -89,18 +93,33 @@ const buildRepoUrl = (config: RepositoryConfig): string => {
const buildEnv = async (config: RepositoryConfig) => {
const env: Record<string, string> = {
RESTIC_CACHE_DIR: "/var/lib/ironmount/restic/cache",
RESTIC_PASSWORD_FILE: RESTIC_PASS_FILE,
PATH: process.env.PATH || "/usr/local/bin:/usr/bin:/bin",
};
if (config.isExistingRepository && config.customPassword) {
const decryptedPassword = await cryptoUtils.decrypt(config.customPassword);
const passwordFilePath = path.join("/tmp", `ironmount-pass-${crypto.randomBytes(8).toString("hex")}.txt`);
await fs.writeFile(passwordFilePath, decryptedPassword, { mode: 0o600 });
env.RESTIC_PASSWORD_FILE = passwordFilePath;
} else {
env.RESTIC_PASSWORD_FILE = RESTIC_PASS_FILE;
}
switch (config.backend) {
case "s3":
env.AWS_ACCESS_KEY_ID = await cryptoUtils.decrypt(config.accessKeyId);
env.AWS_SECRET_ACCESS_KEY = await cryptoUtils.decrypt(config.secretAccessKey);
break;
case "r2":
env.AWS_ACCESS_KEY_ID = await cryptoUtils.decrypt(config.accessKeyId);
env.AWS_SECRET_ACCESS_KEY = await cryptoUtils.decrypt(config.secretAccessKey);
env.AWS_REGION = "auto";
env.AWS_S3_FORCE_PATH_STYLE = "true";
break;
case "gcs": {
const decryptedCredentials = await cryptoUtils.decrypt(config.credentialsJson);
const credentialsPath = path.join("/tmp", `gcs-credentials-${crypto.randomBytes(8).toString("hex")}.json`);
const credentialsPath = path.join("/tmp", `ironmount-gcs-${crypto.randomBytes(8).toString("hex")}.json`);
await fs.writeFile(credentialsPath, decryptedCredentials, { mode: 0o600 });
env.GOOGLE_PROJECT_ID = config.projectId;
env.GOOGLE_APPLICATION_CREDENTIALS = credentialsPath;
@@ -129,7 +148,7 @@ const init = async (config: RepositoryConfig) => {
if (res.exitCode !== 0) {
logger.error(`Restic init failed: ${res.stderr}`);
return { success: false, error: res.stderr };
return { success: false, error: res.stderr.toString() };
}
logger.info(`Restic repository initialized: ${repoUrl}`);
@@ -422,6 +441,22 @@ const forget = async (config: RepositoryConfig, options: RetentionPolicy, extra:
return { success: true };
};
const deleteSnapshot = async (config: RepositoryConfig, snapshotId: string) => {
const repoUrl = buildRepoUrl(config);
const env = await buildEnv(config);
const args: string[] = ["--repo", repoUrl, "forget", snapshotId, "--prune"];
const res = await $`restic ${args}`.env(env).nothrow();
if (res.exitCode !== 0) {
logger.error(`Restic snapshot deletion failed: ${res.stderr}`);
throw new Error(`Failed to delete snapshot: ${res.stderr}`);
}
return { success: true };
};
const lsNodeSchema = type({
name: "string",
type: "string",
@@ -582,6 +617,7 @@ export const restic = {
restore,
snapshots,
forget,
deleteSnapshot,
unlock,
ls,
check,

View File

@@ -1,5 +1,6 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "@ironmount/client",

View File

@@ -15,10 +15,14 @@ services:
ports:
- "4096:4096"
volumes:
- /etc/localtime:/etc/localtime:ro
- /var/lib/ironmount:/var/lib/ironmount
- ./app:/app/app
# - ~/.config/rclone:/root/.config/rclone
- ~/.config/rclone:/root/.config/rclone
- /var/lib/ironmount:/var/lib/ironmount:rshared
- /run/docker/plugins:/run/docker/plugins
- /var/run/docker.sock:/var/run/docker.sock
ironmount-prod:
build:
@@ -34,6 +38,7 @@ services:
ports:
- "4096:4096"
volumes:
- /etc/localtime:/etc/localtime:ro
- /var/lib/ironmount:/var/lib/ironmount:rshared
- /run/docker/plugins:/run/docker/plugins
- /var/run/docker.sock:/var/run/docker.sock

View File

@@ -1 +0,0 @@
proj_Nwis7nYU1DiPGTtNlwRKBVtdgo5cOWPsnwbtxj2Urg0

View File

@@ -1,4 +0,0 @@
docker run --rm -it -v nicolas:/data alpine sh -lc 'echo hello > /data/hi && cat /data/hi'
mount -t davfs http://192.168.2.42 /mnt/webdav

Binary file not shown.

After

Width:  |  Height:  |  Size: 296 KiB