refactor: rclone system capability

This commit is contained in:
Nicolas Meienberger
2025-11-11 21:44:04 +01:00
parent 36b0282d18
commit 52e38a6242
9 changed files with 114 additions and 67 deletions

View File

@@ -22,6 +22,7 @@ import {
browseFilesystem, browseFilesystem,
listRepositories, listRepositories,
createRepository, createRepository,
listRcloneRemotes,
deleteRepository, deleteRepository,
getRepository, getRepository,
listSnapshots, listSnapshots,
@@ -29,7 +30,6 @@ import {
listSnapshotFiles, listSnapshotFiles,
restoreSnapshot, restoreSnapshot,
doctorRepository, doctorRepository,
listRcloneRemotes,
listBackupSchedules, listBackupSchedules,
createBackupSchedule, createBackupSchedule,
deleteBackupSchedule, deleteBackupSchedule,
@@ -75,6 +75,7 @@ import type {
ListRepositoriesData, ListRepositoriesData,
CreateRepositoryData, CreateRepositoryData,
CreateRepositoryResponse, CreateRepositoryResponse,
ListRcloneRemotesData,
DeleteRepositoryData, DeleteRepositoryData,
DeleteRepositoryResponse, DeleteRepositoryResponse,
GetRepositoryData, GetRepositoryData,
@@ -85,7 +86,6 @@ import type {
RestoreSnapshotResponse, RestoreSnapshotResponse,
DoctorRepositoryData, DoctorRepositoryData,
DoctorRepositoryResponse, DoctorRepositoryResponse,
ListRcloneRemotesData,
ListBackupSchedulesData, ListBackupSchedulesData,
CreateBackupScheduleData, CreateBackupScheduleData,
CreateBackupScheduleResponse, CreateBackupScheduleResponse,
@@ -739,6 +739,27 @@ export const createRepositoryMutation = (
return mutationOptions; return mutationOptions;
}; };
export const listRcloneRemotesQueryKey = (options?: Options<ListRcloneRemotesData>) =>
createQueryKey("listRcloneRemotes", options);
/**
* List all configured rclone remotes on the host system
*/
export const listRcloneRemotesOptions = (options?: Options<ListRcloneRemotesData>) => {
return queryOptions({
queryFn: async ({ queryKey, signal }) => {
const { data } = await listRcloneRemotes({
...options,
...queryKey[0],
signal,
throwOnError: true,
});
return data;
},
queryKey: listRcloneRemotesQueryKey(options),
});
};
/** /**
* Delete a repository * Delete a repository
*/ */
@@ -920,27 +941,6 @@ export const doctorRepositoryMutation = (
return mutationOptions; return mutationOptions;
}; };
export const listRcloneRemotesQueryKey = (options?: Options<ListRcloneRemotesData>) =>
createQueryKey("listRcloneRemotes", options);
/**
* List all configured rclone remotes on the host system
*/
export const listRcloneRemotesOptions = (options?: Options<ListRcloneRemotesData>) => {
return queryOptions({
queryFn: async ({ queryKey, signal }) => {
const { data } = await listRcloneRemotes({
...options,
...queryKey[0],
signal,
throwOnError: true,
});
return data;
},
queryKey: listRcloneRemotesQueryKey(options),
});
};
export const listBackupSchedulesQueryKey = (options?: Options<ListBackupSchedulesData>) => export const listBackupSchedulesQueryKey = (options?: Options<ListBackupSchedulesData>) =>
createQueryKey("listBackupSchedules", options); createQueryKey("listBackupSchedules", options);

View File

@@ -46,6 +46,8 @@ import type {
ListRepositoriesResponses, ListRepositoriesResponses,
CreateRepositoryData, CreateRepositoryData,
CreateRepositoryResponses, CreateRepositoryResponses,
ListRcloneRemotesData,
ListRcloneRemotesResponses,
DeleteRepositoryData, DeleteRepositoryData,
DeleteRepositoryResponses, DeleteRepositoryResponses,
GetRepositoryData, GetRepositoryData,
@@ -60,8 +62,6 @@ import type {
RestoreSnapshotResponses, RestoreSnapshotResponses,
DoctorRepositoryData, DoctorRepositoryData,
DoctorRepositoryResponses, DoctorRepositoryResponses,
ListRcloneRemotesData,
ListRcloneRemotesResponses,
ListBackupSchedulesData, ListBackupSchedulesData,
ListBackupSchedulesResponses, ListBackupSchedulesResponses,
CreateBackupScheduleData, CreateBackupScheduleData,
@@ -357,6 +357,18 @@ export const createRepository = <ThrowOnError extends boolean = false>(
}); });
}; };
/**
* List all configured rclone remotes on the host system
*/
export const listRcloneRemotes = <ThrowOnError extends boolean = false>(
options?: Options<ListRcloneRemotesData, ThrowOnError>,
) => {
return (options?.client ?? _heyApiClient).get<ListRcloneRemotesResponses, unknown, ThrowOnError>({
url: "/api/v1/repositories/rclone-remotes",
...options,
});
};
/** /**
* Delete a repository * Delete a repository
*/ */
@@ -445,18 +457,6 @@ export const doctorRepository = <ThrowOnError extends boolean = false>(
}); });
}; };
/**
* List all configured rclone remotes on the host system
*/
export const listRcloneRemotes = <ThrowOnError extends boolean = false>(
options?: Options<ListRcloneRemotesData, ThrowOnError>,
) => {
return (options?.client ?? _heyApiClient).get<ListRcloneRemotesResponses, unknown, ThrowOnError>({
url: "/api/v1/repositories/rclone-remotes",
...options,
});
};
/** /**
* List all backup schedules * List all backup schedules
*/ */

View File

@@ -828,6 +828,25 @@ export type CreateRepositoryResponses = {
export type CreateRepositoryResponse = CreateRepositoryResponses[keyof CreateRepositoryResponses]; export type CreateRepositoryResponse = CreateRepositoryResponses[keyof CreateRepositoryResponses];
export type ListRcloneRemotesData = {
body?: never;
path?: never;
query?: never;
url: "/api/v1/repositories/rclone-remotes";
};
export type ListRcloneRemotesResponses = {
/**
* List of rclone remotes
*/
200: Array<{
name: string;
type: string;
}>;
};
export type ListRcloneRemotesResponse = ListRcloneRemotesResponses[keyof ListRcloneRemotesResponses];
export type DeleteRepositoryData = { export type DeleteRepositoryData = {
body?: never; body?: never;
path: { path: {
@@ -1052,25 +1071,6 @@ export type DoctorRepositoryResponses = {
export type DoctorRepositoryResponse = DoctorRepositoryResponses[keyof DoctorRepositoryResponses]; export type DoctorRepositoryResponse = DoctorRepositoryResponses[keyof DoctorRepositoryResponses];
export type ListRcloneRemotesData = {
body?: never;
path?: never;
query?: never;
url: "/api/v1/repositories/rclone-remotes";
};
export type ListRcloneRemotesResponses = {
/**
* List of rclone remotes
*/
200: Array<{
name: string;
type: string;
}>;
};
export type ListRcloneRemotesResponse = ListRcloneRemotesResponses[keyof ListRcloneRemotesResponses];
export type ListBackupSchedulesData = { export type ListBackupSchedulesData = {
body?: never; body?: never;
path?: never; path?: never;
@@ -1648,6 +1648,7 @@ export type GetSystemInfoResponses = {
200: { 200: {
capabilities: { capabilities: {
docker: boolean; docker: boolean;
rclone: boolean;
}; };
}; };
}; };

View File

@@ -9,10 +9,11 @@ import { Button } from "./ui/button";
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "./ui/form"; import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "./ui/form";
import { Input } from "./ui/input"; import { Input } from "./ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
import { listRcloneRemotesOptions } from "~/api-client/@tanstack/react-query.gen"; import { getSystemInfoOptions, listRcloneRemotesOptions } from "~/api-client/@tanstack/react-query.gen";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Alert, AlertDescription } from "./ui/alert"; import { Alert, AlertDescription } from "./ui/alert";
import { ExternalLink } from "lucide-react"; import { ExternalLink } from "lucide-react";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
export const formSchema = type({ export const formSchema = type({
name: "2<=string<=32", name: "2<=string<=32",
@@ -71,6 +72,10 @@ export const CreateRepositoryForm = ({
} }
}, [watchedBackend, watchedName, form]); }, [watchedBackend, watchedName, form]);
const { data: systemInfo } = useQuery({
...getSystemInfoOptions(),
});
return ( return (
<Form {...form}> <Form {...form}>
<form id={formId} onSubmit={form.handleSubmit(onSubmit)} className={cn("space-y-4", className)}> <form id={formId} onSubmit={form.handleSubmit(onSubmit)} className={cn("space-y-4", className)}>
@@ -113,7 +118,16 @@ export const CreateRepositoryForm = ({
<SelectItem value="s3">S3</SelectItem> <SelectItem value="s3">S3</SelectItem>
<SelectItem value="gcs">Google Cloud Storage</SelectItem> <SelectItem value="gcs">Google Cloud Storage</SelectItem>
<SelectItem value="azure">Azure Blob Storage</SelectItem> <SelectItem value="azure">Azure Blob Storage</SelectItem>
<SelectItem value="rclone">rclone (40+ cloud providers)</SelectItem> <Tooltip>
<TooltipTrigger>
<SelectItem disabled={!systemInfo?.capabilities.rclone} value="rclone">
rclone (40+ cloud providers)
</SelectItem>
</TooltipTrigger>
<TooltipContent className={cn({ hidden: systemInfo?.capabilities.rclone })}>
<p>Setup rclone to use this backend</p>
</TooltipContent>
</Tooltip>
</SelectContent> </SelectContent>
</Select> </Select>
<FormDescription>Choose the storage backend for this repository.</FormDescription> <FormDescription>Choose the storage backend for this repository.</FormDescription>

View File

@@ -29,6 +29,7 @@ import { getVolume } from "~/api-client";
import { VolumeInfoTabContent } from "../tabs/info"; import { VolumeInfoTabContent } from "../tabs/info";
import { FilesTabContent } from "../tabs/files"; import { FilesTabContent } from "../tabs/files";
import { DockerTabContent } from "../tabs/docker"; import { DockerTabContent } from "../tabs/docker";
import { Tooltip, TooltipContent, TooltipTrigger } from "~/components/ui/tooltip";
export function meta({ params }: Route.MetaArgs) { export function meta({ params }: Route.MetaArgs) {
return [ return [
@@ -150,7 +151,16 @@ export default function VolumeDetails({ loaderData }: Route.ComponentProps) {
<TabsList className="mb-2"> <TabsList className="mb-2">
<TabsTrigger value="info">Configuration</TabsTrigger> <TabsTrigger value="info">Configuration</TabsTrigger>
<TabsTrigger value="files">Files</TabsTrigger> <TabsTrigger value="files">Files</TabsTrigger>
{dockerAvailable && <TabsTrigger value="docker">Docker</TabsTrigger>} <Tooltip>
<TooltipTrigger>
<TabsTrigger disabled={!dockerAvailable} value="docker">
Docker
</TabsTrigger>
</TooltipTrigger>
<TooltipContent className={cn({ hidden: dockerAvailable })}>
<p>Enable Docker support to access this tab.</p>
</TooltipContent>
</Tooltip>
</TabsList> </TabsList>
<TabsContent value="info"> <TabsContent value="info">
<VolumeInfoTabContent volume={volume} statfs={statfs} /> <VolumeInfoTabContent volume={volume} statfs={statfs} />

View File

@@ -4,6 +4,7 @@ import { logger } from "../utils/logger";
export type SystemCapabilities = { export type SystemCapabilities = {
docker: boolean; docker: boolean;
rclone: boolean;
}; };
let capabilitiesPromise: Promise<SystemCapabilities> | null = null; let capabilitiesPromise: Promise<SystemCapabilities> | null = null;
@@ -28,6 +29,7 @@ export async function getCapabilities(): Promise<SystemCapabilities> {
async function detectCapabilities(): Promise<SystemCapabilities> { async function detectCapabilities(): Promise<SystemCapabilities> {
return { return {
docker: await detectDocker(), docker: await detectDocker(),
rclone: await detectRclone(),
}; };
} }
@@ -53,3 +55,22 @@ async function detectDocker(): Promise<boolean> {
return false; return false;
} }
} }
/**
* Checks if rclone is available by:
* 1. Checking if /root/.config/rclone directory exists and is accessible
*/
async function detectRclone(): Promise<boolean> {
try {
await fs.access("/root/.config/rclone");
logger.info("rclone capability: enabled");
return true;
} catch (_) {
logger.warn(
"rclone capability: disabled. " +
"To enable: mount /root/.config/rclone in docker-compose.yml",
);
return false;
}
}

View File

@@ -23,13 +23,13 @@ export const eventsController = new Hono().get("/", (c) => {
scheduleId: number; scheduleId: number;
volumeName: string; volumeName: string;
repositoryName: string; repositoryName: string;
secondsElapsed: number; seconds_elapsed: number;
percentDone: number; percent_done: number;
totalFiles: number; total_files: number;
filesDone: number; files_done: number;
totalBytes: number; total_bytes: number;
bytesDone: number; bytes_done: number;
currentFiles: string[]; current_files: string[];
}) => { }) => {
stream.writeSSE({ stream.writeSSE({
data: JSON.stringify(data), data: JSON.stringify(data),

View File

@@ -3,6 +3,7 @@ import { describeRoute, resolver } from "hono-openapi";
export const capabilitiesSchema = type({ export const capabilitiesSchema = type({
docker: "boolean", docker: "boolean",
rclone: "boolean",
}); });
export const systemInfoResponse = type({ export const systemInfoResponse = type({

View File

@@ -19,7 +19,7 @@ services:
- ./apps/client/app:/app/apps/client/app - ./apps/client/app:/app/apps/client/app
- ./apps/server/src:/app/apps/server/src - ./apps/server/src:/app/apps/server/src
- ~/.config/rclone:/root/.config/rclone # - ~/.config/rclone:/root/.config/rclone
ironmount-prod: ironmount-prod:
build: build: