chore: progress on rclone

This commit is contained in:
Eli Bosley
2025-04-15 14:57:47 -04:00
parent d31d86dc7d
commit 8df0ca58b5
28 changed files with 6182 additions and 49 deletions

View File

@@ -13,6 +13,8 @@ PATHS_PARITY_CHECKS=./dev/states/parity-checks.log
PATHS_CONFIG_MODULES=./dev/configs
PATHS_ACTIVATION_BASE=./dev/activation
PATHS_PASSWD=./dev/passwd
PATHS_RCLONE_SOCKET=./dev/rclone-socket
PATHS_LOG_BASE=./dev/log # Where we store logs
ENVIRONMENT="development"
NODE_ENV="development"
PORT="3001"

View File

@@ -1299,20 +1299,15 @@ type DockerContainer implements Node {
"""Total size of all the files in the container"""
sizeRootFs: Int
labels: JSONObject
labels: JSON
state: ContainerState!
status: String!
hostConfig: ContainerHostConfig
networkSettings: JSONObject
mounts: [JSONObject!]
networkSettings: JSON
mounts: [JSON!]
autoStart: Boolean!
}
"""
The `JSONObject` scalar type represents JSON objects as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf).
"""
scalar JSONObject @specifiedBy(url: "http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf")
enum ContainerState {
RUNNING
EXITED
@@ -1325,15 +1320,15 @@ type DockerNetwork implements Node {
scope: String!
driver: String!
enableIPv6: Boolean!
ipam: JSONObject!
ipam: JSON!
internal: Boolean!
attachable: Boolean!
ingress: Boolean!
configFrom: JSONObject!
configFrom: JSON!
configOnly: Boolean!
containers: JSONObject!
options: JSONObject!
labels: JSONObject!
containers: JSON!
options: JSON!
labels: JSON!
}
type Docker implements Node {
@@ -1342,6 +1337,64 @@ type Docker implements Node {
networks(skipCache: Boolean! = false): [DockerNetwork!]!
}
type FlashBackupStatus {
"""Status message indicating the outcome of the backup initiation."""
status: String!
"""Job ID if available, can be used to check job status."""
jobId: String
}
type RCloneDrive {
"""Provider name"""
name: String!
"""Provider options and configuration schema"""
options: JSON!
}
type RCloneProviderOption {
name: String!
help: String!
provider: String!
default: JSON
value: JSON
shortOpt: String
hide: Boolean
required: Boolean
isPassword: Boolean
noPrefix: Boolean
advanced: Boolean
defaultStr: String
valueStr: String
type: String
examples: [RCloneProviderOptionExample!]
}
type RCloneProviderOptionExample {
value: String!
help: String!
provider: String!
}
type RCloneBackupConfigForm {
id: ID!
dataSchema: JSON!
uiSchema: JSON!
}
type RCloneBackupSettings {
configForm: RCloneBackupConfigForm!
drives: [RCloneDrive!]!
remotes: [String!]!
}
type RCloneRemote {
name: String!
type: String!
config: JSON!
}
type Flash implements Node {
id: PrefixedID!
guid: String!
@@ -1543,6 +1596,7 @@ type Query {
docker: Docker!
disks: [Disk!]!
disk(id: PrefixedID!): Disk!
rcloneBackup: RCloneBackupSettings!
health: String!
getDemo: String!
}
@@ -1578,6 +1632,10 @@ type Mutation {
setupRemoteAccess(input: SetupRemoteAccessInput!): Boolean!
setAdditionalAllowedOrigins(input: AllowedOriginInput!): [String!]!
enableDynamicRemoteAccess(input: EnableDynamicRemoteAccessInput!): Boolean!
"""Initiates a flash drive backup using a configured remote."""
initiateFlashBackup(input: InitiateFlashBackupInput!): FlashBackupStatus!
createRCloneRemote(input: CreateRCloneRemoteInput!): RCloneRemote!
setDemo: String!
}
@@ -1674,6 +1732,28 @@ input AccessUrlInput {
ipv6: URL
}
input InitiateFlashBackupInput {
"""The name of the remote configuration to use for the backup."""
remoteName: String!
"""Source path to backup (typically the flash drive)."""
sourcePath: String!
"""Destination path on the remote."""
destinationPath: String!
"""
Additional options for the backup operation, such as --dry-run or --transfers.
"""
options: JSON
}
input CreateRCloneRemoteInput {
name: String!
type: String!
config: JSON!
}
type Subscription {
displaySubscription: Display!
infoSubscription: Info!

View File

@@ -21,6 +21,9 @@ const initialState = {
),
'docker-autostart': '/var/lib/docker/unraid-autostart' as const,
'docker-socket': '/var/run/docker.sock' as const,
'rclone-socket': resolvePath(
process.env.PATHS_RCLONE_SOCKET ?? ('/var/run/rclone.socket' as const)
),
'parity-checks': resolvePath(
process.env.PATHS_PARITY_CHECKS ?? ('/boot/config/parity-checks.log' as const)
),
@@ -54,8 +57,8 @@ const initialState = {
('/boot/config/plugins/dynamix.my.servers/fb_keepalive' as const),
'keyfile-base': resolvePath(process.env.PATHS_KEYFILE_BASE ?? ('/boot/config' as const)),
'machine-id': resolvePath(process.env.PATHS_MACHINE_ID ?? ('/var/lib/dbus/machine-id' as const)),
'log-base': resolvePath('/var/log/unraid-api/' as const),
'unraid-log-base': resolvePath('/var/log/' as const),
'log-base': process.env.PATHS_LOG_BASE ?? resolvePath('/var/log/unraid-api/' as const),
'unraid-log-base': process.env.PATHS_UNRAID_LOG_BASE ?? resolvePath('/var/log/' as const),
'var-run': '/var/run' as const,
// contains sess_ files that correspond to authenticated user sessions
'auth-sessions': process.env.PATHS_AUTH_SESSIONS ?? '/var/lib/php',

View File

@@ -52,7 +52,7 @@ export class AuthService {
async validateCookiesWithCsrfToken(request: FastifyRequest): Promise<UserAccount> {
try {
if (!this.validateCsrfToken(request.headers['x-csrf-token'] || request.query.csrf_token)) {
if (request.method !== 'GET' && !request.url.startsWith('/graphql/api/rclone-webgui/') && (!this.validateCsrfToken(request.headers['x-csrf-token'] || request.query.csrf_token))) {
throw new UnauthorizedException('Invalid CSRF token');
}

View File

@@ -1,8 +1,8 @@
import { Logger } from '@nestjs/common';
import { Args, Mutation, Query, ResolveField, Resolver } from '@nestjs/graphql';
import { Layout } from '@jsonforms/core';
import { GraphQLJSON, GraphQLJSONObject } from 'graphql-scalars';
import { type Layout } from '@jsonforms/core';
import { GraphQLJSON } from 'graphql-scalars';
import { getAllowedOrigins } from '@app/common/allowed-origins.js';
import { setupRemoteAccessThunk } from '@app/store/actions/setup-remote-access.js';
@@ -46,7 +46,7 @@ export class ConnectSettingsResolver {
};
}
@ResolveField(() => GraphQLJSONObject)
@ResolveField(() => GraphQLJSON)
public async uiSchema(): Promise<Layout> {
const { elements } = await this.connectSettingsService.buildSettingsSchema();
return {

View File

@@ -95,8 +95,8 @@ export class ConnectSettingsService {
getState: store.getState,
dispatch: store.dispatch,
});
}
return true;
}
return true;
}
async isSignedIn(): Promise<boolean> {

View File

@@ -1,6 +1,6 @@
import { Field, ID, Int, ObjectType, registerEnumType } from '@nestjs/graphql';
import { GraphQLJSONObject, GraphQLPort } from 'graphql-scalars';
import { GraphQLJSON, GraphQLPort } from 'graphql-scalars';
import { Node } from '@app/unraid-api/graph/resolvers/base.model.js';
@@ -93,7 +93,7 @@ export class DockerContainer extends Node {
@Field(() => Int, { nullable: true, description: 'Total size of all the files in the container' })
sizeRootFs?: number;
@Field(() => GraphQLJSONObject, { nullable: true })
@Field(() => GraphQLJSON, { nullable: true })
labels?: Record<string, any>;
@Field(() => ContainerState)
@@ -105,10 +105,10 @@ export class DockerContainer extends Node {
@Field(() => ContainerHostConfig, { nullable: true })
hostConfig?: ContainerHostConfig;
@Field(() => GraphQLJSONObject, { nullable: true })
@Field(() => GraphQLJSON, { nullable: true })
networkSettings?: Record<string, any>;
@Field(() => [GraphQLJSONObject], { nullable: true })
@Field(() => [GraphQLJSON], { nullable: true })
mounts?: Record<string, any>[];
@Field(() => Boolean)
@@ -132,7 +132,7 @@ export class DockerNetwork extends Node {
@Field(() => Boolean)
enableIPv6!: boolean;
@Field(() => GraphQLJSONObject)
@Field(() => GraphQLJSON)
ipam!: Record<string, any>;
@Field(() => Boolean)
@@ -144,19 +144,19 @@ export class DockerNetwork extends Node {
@Field(() => Boolean)
ingress!: boolean;
@Field(() => GraphQLJSONObject)
@Field(() => GraphQLJSON)
configFrom!: Record<string, any>;
@Field(() => Boolean)
configOnly!: boolean;
@Field(() => GraphQLJSONObject)
@Field(() => GraphQLJSON)
containers!: Record<string, any>;
@Field(() => GraphQLJSONObject)
@Field(() => GraphQLJSON)
options!: Record<string, any>;
@Field(() => GraphQLJSONObject)
@Field(() => GraphQLJSON)
labels!: Record<string, any>;
}

View File

@@ -0,0 +1,53 @@
import { Field, InputType, ObjectType } from '@nestjs/graphql';
import { GraphQLJSON } from 'graphql-scalars';
@InputType()
export class InitiateFlashBackupInput {
@Field(() => String, { description: 'The name of the remote configuration to use for the backup.' })
remoteName!: string;
@Field(() => String, { description: 'Source path to backup (typically the flash drive).' })
sourcePath!: string;
@Field(() => String, { description: 'Destination path on the remote.' })
destinationPath!: string;
@Field(() => GraphQLJSON, {
description: 'Additional options for the backup operation, such as --dry-run or --transfers.',
nullable: true,
})
options?: Record<string, unknown>;
}
@ObjectType()
export class FlashBackupStatus {
@Field(() => String, {
description: 'Status message indicating the outcome of the backup initiation.',
})
status!: string;
@Field(() => String, {
description: 'Job ID if available, can be used to check job status.',
nullable: true,
})
jobId?: string;
}
@ObjectType()
export class FlashBackupJob {
@Field(() => String, { description: 'Job ID' })
id!: string;
@Field(() => String, { description: 'Job type (e.g., sync/copy)' })
type!: string;
@Field(() => GraphQLJSON, { description: 'Job status and statistics' })
stats!: Record<string, unknown>;
}
@ObjectType()
export class RCloneWebGuiInfo {
@Field()
url!: string;
}

View File

@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { FlashBackupResolver } from './flash-backup.resolver.js';
import { RCloneModule } from '@app/unraid-api/graph/resolvers/rclone/rclone.module.js';
@Module({
imports: [RCloneModule],
providers: [FlashBackupResolver],
exports: [],
})
export class FlashBackupModule {}

View File

@@ -0,0 +1,25 @@
import { Inject, Logger } from '@nestjs/common';
import { Args, Mutation, Resolver } from '@nestjs/graphql';
import {
FlashBackupStatus,
InitiateFlashBackupInput,
} from './flash-backup.model.js';
import { RCloneService } from '@app/unraid-api/graph/resolvers/rclone/rclone.service.js';
@Resolver()
export class FlashBackupResolver {
private readonly logger = new Logger(FlashBackupResolver.name);
constructor(private readonly rcloneService: RCloneService) {}
@Mutation(() => FlashBackupStatus, {
description: 'Initiates a flash drive backup using a configured remote.',
})
async initiateFlashBackup(
@Args('input') input: InitiateFlashBackupInput
): Promise<FlashBackupStatus> {
throw new Error('Not implemented');
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,427 @@
import { RuleEffect, type SchemaBasedCondition, type JsonSchema, JsonSchema7 } from '@jsonforms/core';
import type { DataSlice, SettingSlice, UIElement } from '@app/unraid-api/types/json-forms.js';
import { mergeSettingSlices } from '@app/unraid-api/types/json-forms.js';
import { config as rcloneConfig } from './config.js';
/**
* Type definitions for RClone config options
*/
export interface RCloneOptionDef {
Name: string;
Help: string;
Provider: string;
Default: any;
Value: any | null;
ShortOpt: string;
Hide: number;
Required: boolean;
IsPassword: boolean;
NoPrefix: boolean;
Advanced: boolean;
Type?: string;
Options?: string[];
}
export interface RCloneProviderDef {
Name: string;
Description: string;
Prefix: string;
Options: RCloneOptionDef[];
}
/**
* Translates RClone config option to JsonSchema properties
*/
function translateRCloneOptionToJsonSchema(option: RCloneOptionDef): JsonSchema7 {
const schema: JsonSchema7 = {
type: getJsonSchemaType(option.Type || 'string'),
title: option.Name,
description: option.Help || '',
};
// Add default value if available
if (option.Default !== undefined && option.Default !== '') {
schema.default = option.Default;
}
// Add enum values if available
if (option.Options && option.Options.length > 0) {
schema.enum = option.Options;
}
// Add validation constraints
if (option.Required) {
if (schema.type === 'string') {
schema.minLength = 1;
} else if (schema.type === 'number') {
schema.minimum = 0;
}
}
return schema;
}
/**
* Get available provider types from RClone config
*/
export function getAvailableProviderTypes(): string[] {
return rcloneConfig.map(provider => provider.Name);
}
/**
* Get provider options for a specific provider
*/
export function getProviderOptions(providerName: string): Record<string, RCloneOptionDef> {
const provider = rcloneConfig.find(p => p.Name === providerName);
if (!provider) return {};
return provider.Options.reduce((acc, option) => {
acc[option.Name] = option;
return acc;
}, {} as Record<string, RCloneOptionDef>);
}
/**
* Generates the UI schema for RClone remote configuration
*/
export function getRcloneConfigFormSchema(
providerTypes: string[] = [],
selectedProvider: string = '',
providerOptions: Record<string, RCloneOptionDef> = {}
): SettingSlice {
// If provider types not provided, get from config
if (providerTypes.length === 0) {
providerTypes = getAvailableProviderTypes();
}
// Combine all form slices for the complete schema
const slices = [
getBasicConfigSlice(providerTypes),
getProviderConfigSlice(selectedProvider, providerOptions),
getAdvancedConfigSlice(selectedProvider, providerOptions),
];
return mergeSettingSlices(slices);
}
/**
* Step 1: Basic configuration - name and type selection
*/
function getBasicConfigSlice(providerTypes: string[]): SettingSlice {
// Create UI elements for basic configuration (Step 1)
const basicConfigElements: UIElement[] = [
{
type: 'Control',
scope: '#/properties/name',
label: 'Name of this remote (For your reference)',
options: {
placeholder: 'Enter a name',
},
},
{
type: 'Control',
scope: '#/properties/type',
label: 'Storage Provider Type',
options: {
format: 'dropdown',
description: 'Select the cloud storage provider to use for this remote.',
},
},
{
type: 'Label',
text: 'Documentation',
options: {
format: 'documentation',
description: 'For more information, refer to the <a href="https://rclone.org/commands/rclone_config/" target="_blank">RClone Config Documentation</a>.',
},
},
];
// Define the data schema for basic configuration
const basicConfigProperties: Record<string, JsonSchema7> = {
name: {
type: 'string',
title: 'Remote Name',
description: 'Name to identify this remote configuration',
pattern: '^[a-zA-Z0-9_-]+$',
minLength: 1,
maxLength: 50,
},
type: {
type: 'string',
title: 'Provider Type',
default: providerTypes.length > 0 ? providerTypes[0] : '',
oneOf: providerTypes.map(type => ({ const: type, title: type }))
},
};
return {
properties: basicConfigProperties as unknown as DataSlice,
elements: basicConfigElements,
};
}
/**
* Step 2: Provider-specific configuration based on the selected provider
*/
function getProviderConfigSlice(
selectedProvider: string,
providerOptions: Record<string, RCloneOptionDef>
): SettingSlice {
// Default elements for when a provider isn't selected or options aren't loaded
let providerConfigElements: UIElement[] = [
{
type: 'Label',
text: 'Provider Configuration',
options: {
format: 'loading',
description: 'Select a provider type first to see provider-specific options.',
},
},
];
// Default properties when no provider is selected
let providerConfigProperties: Record<string, JsonSchema7> = {};
// If we have a selected provider and options for it
if (selectedProvider && Object.keys(providerOptions).length > 0) {
// Create dynamic UI elements based on provider options
providerConfigElements = Object.entries(providerOptions).map(([key, option]) => {
if (option.Advanced === true) {
return null; // Skip advanced options for this step
}
return {
type: 'Control',
scope: `#/properties/parameters/properties/${key}`,
label: option.Help || key,
options: {
placeholder: option.Default?.toString() || '',
description: option.Help || '',
required: option.Required || false,
format: getFormatForType(option.Type, option.Options),
hide: option.Hide === 1,
},
};
}).filter(Boolean) as UIElement[];
// No options available case
if (providerConfigElements.length === 0) {
providerConfigElements = [
{
type: 'Label',
text: 'No Configuration Required',
options: {
description: 'This provider does not require additional configuration, or all options are advanced.',
},
},
];
}
// Create dynamic properties schema based on provider options
const paramProperties: Record<string, JsonSchema7> = {};
Object.entries(providerOptions).forEach(([key, option]) => {
if (option.Advanced === true) {
return; // Skip advanced options for this step
}
paramProperties[key] = translateRCloneOptionToJsonSchema(option);
});
providerConfigProperties = {
parameters: {
type: 'object',
properties: paramProperties,
},
};
}
return {
properties: providerConfigProperties as unknown as DataSlice,
elements: providerConfigElements,
};
}
/**
* Step 3: Advanced configuration options for the selected provider
*/
function getAdvancedConfigSlice(
selectedProvider: string,
providerOptions: Record<string, RCloneOptionDef>
): SettingSlice {
// Default elements when no advanced options are available
let advancedConfigElements: UIElement[] = [
{
type: 'Label',
text: 'Advanced Configuration',
options: {
format: 'note',
description: 'No advanced options available for this provider.',
},
},
];
// Default properties
let advancedConfigProperties: Record<string, JsonSchema7> = {};
// If we have a selected provider and options
if (selectedProvider && Object.keys(providerOptions).length > 0) {
// Create dynamic UI elements for advanced options
const advancedElements = Object.entries(providerOptions).map(([key, option]) => {
if (!option.Advanced) {
return null; // Skip non-advanced options
}
return {
type: 'Control',
scope: `#/properties/parameters/properties/${key}`,
label: option.Help || key,
options: {
placeholder: option.Default?.toString() || '',
description: option.Help || '',
required: option.Required || false,
format: getFormatForType(option.Type, option.Options),
hide: option.Hide === 1,
},
};
}).filter(Boolean) as UIElement[];
// Use default message if no advanced options
if (advancedElements.length > 0) {
advancedConfigElements = advancedElements;
}
// Create dynamic properties schema for advanced options
const advancedProperties: Record<string, JsonSchema7> = {};
Object.entries(providerOptions).forEach(([key, option]) => {
if (!option.Advanced) {
return; // Skip non-advanced options
}
advancedProperties[key] = translateRCloneOptionToJsonSchema(option);
});
// Only add if we have advanced options
if (Object.keys(advancedProperties).length > 0) {
advancedConfigProperties = {
parameters: {
type: 'object',
properties: advancedProperties,
},
};
}
}
return {
properties: advancedConfigProperties as unknown as DataSlice,
elements: advancedConfigElements,
};
}
/**
* Helper function to convert RClone option types to JSON Schema types
*/
function getJsonSchemaType(rcloneType: string): string {
switch (rcloneType?.toLowerCase()) {
case 'int':
case 'size':
case 'duration':
return 'number';
case 'bool':
return 'boolean';
case 'string':
case 'text':
default:
return 'string';
}
}
/**
* Helper function to get the appropriate UI format based on RClone option type
*/
function getFormatForType(rcloneType: string = '', options: string[] | null = null): string {
if (options && options.length > 0) {
return 'dropdown';
}
switch (rcloneType?.toLowerCase()) {
case 'int':
case 'size':
return 'number';
case 'duration':
return 'duration';
case 'bool':
return 'checkbox';
case 'password':
return 'password';
case 'text':
return 'textarea';
default:
return 'text';
}
}
/**
* Returns a combined form schema for the rclone backup configuration UI
*/
export function getRcloneConfigSlice(): SettingSlice {
const elements: UIElement[] = [
{
type: 'Label',
text: 'Configure RClone Backup',
options: {
format: 'title',
description: 'This 3-step process will guide you through setting up your RClone backup configuration.',
},
},
{
type: 'Control',
scope: '#/properties/configStep',
label: 'Configuration Step',
options: {
format: 'stepper',
steps: [
{ label: 'Set up Remote Config', description: 'Name and provider selection' },
{ label: 'Set up Drive', description: 'Provider-specific configuration' },
{ label: 'Advanced Config', description: 'Optional advanced settings' },
],
},
},
{
type: 'Control',
scope: '#/properties/showAdvanced',
label: 'Edit Advanced Options',
options: {
format: 'checkbox',
},
rule: {
effect: RuleEffect.SHOW,
condition: {
scope: '#/properties/configStep',
schema: { enum: [1] } // Only show on step 2
} as SchemaBasedCondition,
},
},
];
// Basic properties for the rclone backup configuration
const properties: Record<string, JsonSchema7> = {
configStep: {
type: 'number',
minimum: 0,
maximum: 2,
default: 0,
},
showAdvanced: {
type: 'boolean',
default: false,
},
};
return {
properties: properties as unknown as DataSlice,
elements,
};
}

View File

@@ -0,0 +1,272 @@
import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import crypto from 'crypto';
import { existsSync } from 'node:fs';
import { mkdir, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { execa } from 'execa';
import got from 'got';
import { RCloneProvider, RCloneProviderOption, RCloneProviderOptionExample, RCloneProviderResponse, RCloneProviderOptionResponse, RCloneProviderTypes } from '@app/unraid-api/graph/resolvers/rclone/rclone.model.js';
@Injectable()
export class RCloneApiService implements OnModuleInit, OnModuleDestroy {
private isInitialized: boolean = false;
private readonly logger = new Logger(RCloneApiService.name);
private rcloneSocketPath: string = '';
private rcloneBaseUrl: string = '';
private rcloneUsername: string = 'unraid-rclone';
private rclonePassword: string = crypto.randomBytes(32).toString('hex');
constructor() {}
async onModuleInit(): Promise<void> {
try {
const { getters } = await import('@app/store/index.js');
// Check if Rclone Socket is running, if not, start it.
this.rcloneSocketPath = getters.paths()['rclone-socket'];
const logFilePath = join(getters.paths()['log-base'], 'rclone-unraid-api.log');
this.logger.log(`RClone socket path: ${this.rcloneSocketPath}`);
this.logger.log(`RClone log file path: ${logFilePath}`);
// Format the base URL for Unix socket
this.rcloneBaseUrl = `http://unix:${this.rcloneSocketPath}:/`;
const socketRunning = await this.checkRcloneSocket(this.rcloneSocketPath);
if (!socketRunning) {
this.startRcloneSocket(this.rcloneSocketPath, logFilePath);
}
this.isInitialized = true;
} catch (error: unknown) {
this.logger.error(`Error initializing FlashBackupService: ${error}`);
this.isInitialized = false;
}
}
async onModuleDestroy(): Promise<void> {
if (this.isInitialized) {
await this.stopRcloneSocket(this.rcloneSocketPath);
}
this.logger.log('FlashBackupService module destroyed');
}
/**
* Starts the RClone RC daemon on the specified socket path
*/
private async startRcloneSocket(socketPath: string, logFilePath: string): Promise<void> {
// Make log file exists
if (!existsSync(logFilePath)) {
this.logger.debug(`Creating log file: ${logFilePath}`);
await mkdir(dirname(logFilePath), { recursive: true });
await writeFile(logFilePath, '', 'utf-8');
}
this.logger.log(`Starting RClone RC daemon on socket: ${socketPath}`);
await execa('rclone', [
'rcd',
'--rc-addr',
socketPath,
'--log-level',
'INFO',
'--rc-user',
this.rcloneUsername,
'--rc-pass',
this.rclonePassword,
'--log-file',
logFilePath,
'--rc-web-gui',
'--rc-web-gui-no-open-browser',
]);
}
private async stopRcloneSocket(socketPath: string): Promise<void> {
this.logger.log(`Stopping RClone RC daemon on socket: ${socketPath}`);
execa('rclone', ['rcd', '--rc-addr', socketPath, '--stop']);
}
/**
* Checks if the RClone socket exists and is running
*/
private async checkRcloneSocket(socketPath: string): Promise<boolean> {
const socketExists = existsSync(socketPath);
if (!socketExists) {
this.logger.warn(`RClone socket does not exist at: ${socketPath}`);
return false;
}
try {
const socketIsRunning = await execa('rclone', ['about']);
return socketIsRunning.exitCode === 0;
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
this.logger.error(`Error checking RClone socket: ${errorMessage}`);
return false;
}
}
/**
* Get providers supported by RClone
*/
async getProviders(): Promise<RCloneProvider[]> {
const response = await this.callRcloneApi('config/providers') as { providers: RCloneProviderResponse[] };
return response?.providers?.map(provider => ({
name: provider.Name,
description: provider.Description,
prefix: provider.Prefix,
options: this.mapProviderOptions(provider.Options),
})) || [];
}
/**
* Get provider types as a simple array
*/
async getProviderTypes(): Promise<RCloneProviderTypes> {
const providers = await this.getProviders();
const types: string[] = providers.map(provider => provider.prefix);
return { types };
}
/**
* Maps RClone provider options from API format to our model format
*/
private mapProviderOptions(options: RCloneProviderOptionResponse[]): RCloneProviderOption[] {
return options.map(option => ({
name: option.Name,
help: option.Help,
provider: option.Provider,
default: option.Default,
value: option.Value,
shortOpt: option.ShortOpt,
hide: option.Hide,
required: option.Required,
isPassword: option.IsPassword,
noPrefix: option.NoPrefix,
advanced: option.Advanced,
defaultStr: option.DefaultStr,
valueStr: option.ValueStr,
type: option.Type,
examples: option.Examples?.map(example => ({
value: example.Value,
help: example.Help,
provider: example.Provider,
})),
}));
}
/**
* List all configured remotes
*/
async listRemotes(): Promise<string[]> {
const response = await this.callRcloneApi('config/listremotes');
return response.remotes || [];
}
/**
* Get detailed config for a specific remote
*/
async getRemoteConfig(name: string): Promise<any> {
return this.callRcloneApi('config/get', { name });
}
/**
* Create a new remote configuration
*/
async createRemote(name: string, type: string, parameters: Record<string, any> = {}): Promise<any> {
// Combine the required parameters for the create request
const params = {
name,
type,
...parameters,
};
this.logger.log(`Creating new remote: ${name} of type: ${type}`);
return this.callRcloneApi('config/create', params);
}
/**
* Update an existing remote configuration
*/
async updateRemote(name: string, parameters: Record<string, any> = {}): Promise<any> {
const params = {
name,
...parameters,
};
this.logger.log(`Updating remote: ${name}`);
return this.callRcloneApi('config/update', params);
}
/**
* Delete a remote configuration
*/
async deleteRemote(name: string): Promise<any> {
this.logger.log(`Deleting remote: ${name}`);
return this.callRcloneApi('config/delete', { name });
}
/**
* Start a backup operation using sync/copy
* This copies a directory from source to destination
*/
async startBackup(
srcPath: string,
dstPath: string,
options: Record<string, any> = {}
): Promise<any> {
this.logger.log(`Starting backup from ${srcPath} to ${dstPath}`);
const params = {
srcFs: srcPath,
dstFs: dstPath,
...options,
};
return this.callRcloneApi('sync/copy', params);
}
/**
* Get the status of a running job
*/
async getJobStatus(jobId: string): Promise<any> {
return this.callRcloneApi('job/status', { jobid: jobId });
}
/**
* List all running jobs
*/
async listRunningJobs(): Promise<any> {
return this.callRcloneApi('job/list');
}
/**
* Generic method to call the RClone RC API
*/
private async callRcloneApi(endpoint: string, params: Record<string, any> = {}): Promise<any> {
try {
const url = `${this.rcloneBaseUrl}/${endpoint}`;
this.logger.debug(`Calling RClone API: ${url} with params: ${JSON.stringify(params)}`);
const response = await got.post(url, {
json: params,
responseType: 'json',
enableUnixSockets: true,
headers: {
Authorization: `Basic ${Buffer.from(`${this.rcloneUsername}:${this.rclonePassword}`).toString('base64')}`,
},
});
return response.body;
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
this.logger.error(`Error calling RClone API (${endpoint}): ${errorMessage} ${error}`);
throw error;
}
}
async serveWebGui(): Promise<{ url: string; username: string; password: string }> {
if (!this.isInitialized) {
throw new Error('RClone service is not initialized');
}
return {
url: this.rcloneBaseUrl,
username: this.rcloneUsername,
password: this.rclonePassword,
};
}
}

View File

@@ -0,0 +1,64 @@
import { Args, Mutation, Parent, ResolveField, Resolver } from '@nestjs/graphql';
import { Logger } from '@nestjs/common';
import { Layout } from '@jsonforms/core';
import { GraphQLJSON } from 'graphql-scalars';
import {
AuthActionVerb,
AuthPossession,
UsePermissions,
} from '@app/unraid-api/graph/directives/use-permissions.directive.js';
import { Resource } from '@app/unraid-api/graph/resolvers/base.model.js';
import { RCloneBackupConfigForm, RCloneRemote, CreateRCloneRemoteInput } from '@app/unraid-api/graph/resolvers/rclone/rclone.model.js';
import { RCloneService } from '@app/unraid-api/graph/resolvers/rclone/rclone.service.js';
import { RCloneApiService } from '@app/unraid-api/graph/resolvers/rclone/rclone-api.service.js';
import { DataSlice } from '@app/unraid-api/types/json-forms.js';
import { RCloneFormService } from '@app/unraid-api/graph/resolvers/rclone/rclone-form.service.js';
@Resolver(() => RCloneBackupConfigForm)
export class RCloneConfigResolver {
private readonly logger = new Logger(RCloneConfigResolver.name);
constructor(
private readonly rcloneService: RCloneService,
private readonly rcloneApiService: RCloneApiService,
private readonly rcloneFormService: RCloneFormService
) {}
@ResolveField(() => GraphQLJSON)
async dataSchema(
@Parent() _parent: RCloneBackupConfigForm
): Promise<{ properties: DataSlice; type: 'object' }> {
const schema = await this.rcloneFormService.dataSchema();
return {
properties: schema.properties as DataSlice,
type: 'object',
};
}
@ResolveField(() => GraphQLJSON)
async uiSchema(@Parent() _parent: RCloneBackupConfigForm): Promise<Layout> {
return this.rcloneFormService.uiSchema();
}
@Mutation(() => RCloneRemote)
@UsePermissions({
action: AuthActionVerb.CREATE,
resource: Resource.FLASH,
possession: AuthPossession.ANY,
})
async createRCloneRemote(@Args('input') input: CreateRCloneRemoteInput): Promise<RCloneRemote> {
try {
await this.rcloneApiService.createRemote(input.name, input.type, input.config);
return {
name: input.name,
type: input.type,
config: input.config
};
} catch (error) {
this.logger.error(`Error creating remote: ${error}`);
throw new Error(`Failed to create remote: ${error}`);
}
}
}

View File

@@ -0,0 +1,118 @@
import { Injectable, Logger } from '@nestjs/common';
import { type Layout } from '@jsonforms/core';
import type { SettingSlice } from '@app/unraid-api/types/json-forms.js';
import { mergeSettingSlices } from '@app/unraid-api/types/json-forms.js';
import {
getRcloneConfigFormSchema,
getRcloneConfigSlice,
getAvailableProviderTypes,
} from './jsonforms/rclone-jsonforms-config.js';
import { RCloneApiService } from './rclone-api.service.js';
/**
* Service responsible for generating form UI schemas and form logic
*/
@Injectable()
export class RCloneFormService {
private readonly logger = new Logger(RCloneFormService.name);
private _providerTypes: string[] = [];
private _providerOptions: Record<string, any> = {};
constructor(
private readonly rcloneApiService: RCloneApiService
) {}
/**
* Loads RClone provider types and options
*/
private async loadProviderInfo(): Promise<void> {
try {
const providersResponse = await this.rcloneApiService.getProviders();
if (providersResponse) {
// Extract provider types
this._providerTypes = providersResponse.map(provider => provider.name);
this._providerOptions = providersResponse.reduce((acc, provider) => {
acc[provider.name] = provider.options;
return acc;
}, {});
this.logger.debug(`Loaded ${this._providerTypes.length} provider types`);
}
} catch (error) {
this.logger.error(`Error loading provider information: ${error}`);
throw error;
}
}
/**
* Builds the complete settings schema
*/
async buildSettingsSchema(
providerTypes: string[] = [],
selectedProvider: string = '',
providerOptions: Record<string, any> = {}
): Promise<SettingSlice> {
// Get the stepper UI and the form based on the selected provider
const baseSlice = getRcloneConfigSlice();
const formSlice = getRcloneConfigFormSchema(
providerTypes,
selectedProvider,
providerOptions
);
return mergeSettingSlices([baseSlice, formSlice]);
}
/**
* Returns the data schema for the form
*/
async dataSchema(
providerTypes: string[] = [],
selectedProvider: string = '',
providerOptions: Record<string, any> = {}
): Promise<Record<string, any>> {
// If provider types weren't provided and we haven't loaded them yet, try to load them
if (providerTypes.length === 0 && this._providerTypes.length === 0) {
await this.loadProviderInfo();
providerTypes = this._providerTypes;
providerOptions = this._providerOptions;
}
const { properties } = await this.buildSettingsSchema(
providerTypes,
selectedProvider,
providerOptions
);
return {
type: 'object',
properties,
};
}
/**
* Returns the UI schema for the form
*/
async uiSchema(
providerTypes: string[] = [],
selectedProvider: string = '',
providerOptions: Record<string, any> = {}
): Promise<Layout> {
// If provider types weren't provided and we haven't loaded them yet, try to load them
if (providerTypes.length === 0 && this._providerTypes.length === 0) {
await this.loadProviderInfo();
providerTypes = this._providerTypes;
providerOptions = this._providerOptions;
}
const { elements } = await this.buildSettingsSchema(
providerTypes,
selectedProvider,
providerOptions
);
return {
type: 'VerticalLayout',
elements,
};
}
}

View File

@@ -0,0 +1,199 @@
import { Field, ID, InputType, ObjectType } from '@nestjs/graphql';
import { type Layout } from '@jsonforms/core';
import { GraphQLJSON } from 'graphql-scalars';
import { DataSlice } from '@app/unraid-api/types/json-forms.js';
@ObjectType()
export class RCloneDrive {
@Field(() => String, { description: 'Provider name' })
name!: string;
@Field(() => GraphQLJSON, { description: 'Provider options and configuration schema' })
options!: Record<string, unknown>;
}
/**
* Raw response format from rclone API
*/
export interface RCloneProviderResponse {
Name: string;
Description: string;
Prefix: string;
Options: RCloneProviderOptionResponse[];
CommandHelp?: string | null;
Aliases?: string[] | null;
Hide?: boolean;
MetadataInfo?: Record<string, unknown>;
}
/**
* Raw option format from rclone API
*/
export interface RCloneProviderOptionResponse {
Name: string;
Help: string;
Provider: string;
Default?: unknown;
Value?: unknown;
ShortOpt?: string;
Hide?: boolean;
Required?: boolean;
IsPassword?: boolean;
NoPrefix?: boolean;
Advanced?: boolean;
DefaultStr?: string;
ValueStr?: string;
Type?: string;
Examples?: Array<{ Value: string; Help: string; Provider: string }>;
}
@ObjectType()
export class RCloneProviderOption {
@Field(() => String)
name!: string;
@Field(() => String)
help!: string;
@Field(() => String)
provider!: string;
@Field(() => GraphQLJSON, { nullable: true })
default?: unknown;
@Field(() => GraphQLJSON, { nullable: true })
value?: unknown;
@Field(() => String, { nullable: true })
shortOpt?: string;
@Field(() => Boolean, { nullable: true })
hide?: boolean;
@Field(() => Boolean, { nullable: true })
required?: boolean;
@Field(() => Boolean, { nullable: true })
isPassword?: boolean;
@Field(() => Boolean, { nullable: true })
noPrefix?: boolean;
@Field(() => Boolean, { nullable: true })
advanced?: boolean;
@Field(() => String, { nullable: true })
defaultStr?: string;
@Field(() => String, { nullable: true })
valueStr?: string;
@Field(() => String, { nullable: true })
type?: string;
@Field(() => [RCloneProviderOptionExample], { nullable: true })
examples?: RCloneProviderOptionExample[];
}
@ObjectType()
export class RCloneProviderOptionExample {
@Field(() => String)
value!: string;
@Field(() => String)
help!: string;
@Field(() => String)
provider!: string;
}
@ObjectType()
export class RCloneProviderTypes {
@Field(() => [String], { description: 'List of all provider types' })
types!: string[];
}
/**
* {
Name: 'jottacloud',
Description: 'Jottacloud',
Prefix: 'jottacloud',
Options: [Array],
CommandHelp: null,
Aliases: null,
Hide: false,
MetadataInfo: [Object]
},
*/
@ObjectType()
export class RCloneProvider {
@Field(() => String)
name!: string;
@Field(() => String)
description!: string;
@Field(() => String)
prefix!: string;
@Field(() => [RCloneProviderOption])
options!: RCloneProviderOption[];
}
@ObjectType()
export class RCloneBackupConfigForm {
@Field(() => ID)
id!: string;
@Field(() => GraphQLJSON)
dataSchema!: { properties: DataSlice; type: 'object' };
@Field(() => GraphQLJSON)
uiSchema!: Layout;
}
@ObjectType()
export class RCloneBackupSettings {
@Field(() => RCloneBackupConfigForm)
configForm!: RCloneBackupConfigForm;
@Field(() => [RCloneDrive])
drives!: RCloneDrive[];
@Field(() => [String])
remotes!: string[];
}
@ObjectType()
export class RCloneRemote {
@Field(() => String)
name!: string;
@Field(() => String)
type!: string;
@Field(() => GraphQLJSON)
config!: Record<string, unknown>;
}
@InputType()
export class CreateRCloneRemoteInput {
@Field(() => String)
name!: string;
@Field(() => String)
type!: string;
@Field(() => GraphQLJSON)
config!: Record<string, unknown>;
}

View File

@@ -0,0 +1,20 @@
import { Module } from '@nestjs/common';
import { RCloneService } from '@app/unraid-api/graph/resolvers/rclone/rclone.service.js';
import { RCloneApiService } from '@app/unraid-api/graph/resolvers/rclone/rclone-api.service.js';
import { RCloneFormService } from '@app/unraid-api/graph/resolvers/rclone/rclone-form.service.js';
import { RCloneBackupSettingsResolver } from '@app/unraid-api/graph/resolvers/rclone/rclone.resolver.js';
import { RCloneConfigResolver } from '@app/unraid-api/graph/resolvers/rclone/rclone-config.resolver.js';
@Module({
imports: [],
providers: [
RCloneService,
RCloneApiService,
RCloneFormService,
RCloneBackupSettingsResolver,
RCloneConfigResolver
],
exports: [RCloneService, RCloneApiService]
})
export class RCloneModule {}

View File

@@ -0,0 +1,69 @@
import { Logger } from '@nestjs/common';
import { Parent, Query, ResolveField, Resolver } from '@nestjs/graphql';
import {
AuthActionVerb,
AuthPossession,
UsePermissions,
} from '@app/unraid-api/graph/directives/use-permissions.directive.js';
import { Resource } from '@app/unraid-api/graph/resolvers/base.model.js';
import { RCloneApiService } from '@app/unraid-api/graph/resolvers/rclone/rclone-api.service.js';
import {
RCloneBackupSettings,
RCloneBackupConfigForm,
RCloneDrive,
} from '@app/unraid-api/graph/resolvers/rclone/rclone.model.js';
import { RCloneService } from './rclone.service.js';
@Resolver(() => RCloneBackupSettings)
export class RCloneBackupSettingsResolver {
private readonly logger = new Logger(RCloneBackupSettingsResolver.name);
constructor(
private readonly rcloneService: RCloneService,
private readonly rcloneApiService: RCloneApiService
) {}
@Query(() => RCloneBackupSettings)
@UsePermissions({
action: AuthActionVerb.READ,
resource: Resource.FLASH,
possession: AuthPossession.ANY,
})
async rcloneBackup(): Promise<RCloneBackupSettings> {
return {} as RCloneBackupSettings;
}
@ResolveField(() => RCloneBackupConfigForm)
async configForm(@Parent() _parent: RCloneBackupSettings): Promise<RCloneBackupConfigForm> {
return {
id: 'rcloneBackupConfigForm',
} as RCloneBackupConfigForm;
}
@ResolveField(() => [RCloneDrive])
async drives(@Parent() _parent: RCloneBackupSettings): Promise<RCloneDrive[]> {
try {
const providers = await this.rcloneApiService.getProviders();
return Object.entries(providers).map(([name, options]) => ({
name,
options: options as Record<string, unknown>,
}));
} catch (error) {
this.logger.error(`Error getting providers: ${error}`);
return [];
}
}
@ResolveField(() => [String])
async remotes(@Parent() _parent: RCloneBackupSettings): Promise<string[]> {
try {
return await this.rcloneApiService.listRemotes();
} catch (error) {
this.logger.error(`Error listing remotes: ${error}`);
return [];
}
}
}

View File

@@ -0,0 +1,89 @@
import { Injectable, Logger } from '@nestjs/common';
import { type Layout } from '@jsonforms/core';
import type { SettingSlice } from '@app/unraid-api/types/json-forms.js';
import { RCloneApiService } from '@app/unraid-api/graph/resolvers/rclone/rclone-api.service.js';
import { RCloneFormService } from '@app/unraid-api/graph/resolvers/rclone/rclone-form.service.js';
/**
* Types for rclone backup configuration UI
*/
export interface RcloneBackupConfigValues {
configStep: number;
showAdvanced: boolean;
name?: string;
type?: string;
parameters?: Record<string, unknown>;
}
@Injectable()
export class RCloneService {
private readonly logger = new Logger(RCloneService.name);
private _providerTypes: string[] = [];
private _providerOptions: Record<string, any> = {};
constructor(
private readonly rcloneApiService: RCloneApiService,
private readonly rcloneFormService: RCloneFormService
) {}
/**
* Get provider types
*/
get providerTypes(): string[] {
return this._providerTypes;
}
/**
* Get provider options
*/
get providerOptions(): Record<string, any> {
return this._providerOptions;
}
/**
* Initializes the service by loading provider information
*/
async onModuleInit(): Promise<void> {
try {
await this.loadProviderInfo();
} catch (error) {
this.logger.error(`Failed to initialize RcloneBackupSettingsService: ${error}`);
}
}
/**
* Loads RClone provider types and options
*/
private async loadProviderInfo(): Promise<void> {
try {
const providersResponse = await this.rcloneApiService.getProviders();
if (providersResponse) {
// Extract provider types
this._providerTypes = Object.keys(providersResponse);
this._providerOptions = providersResponse;
this.logger.debug(`Loaded ${this._providerTypes.length} provider types`);
}
} catch (error) {
this.logger.error(`Error loading provider information: ${error}`);
throw error;
}
}
/**
* Gets current configuration values
*/
async getCurrentSettings(): Promise<RcloneBackupConfigValues> {
return {
configStep: 0,
showAdvanced: false,
};
}
/**
* Gets a list of configured remotes
*/
async getConfiguredRemotes(): Promise<string[]> {
return this.rcloneApiService.listRemotes();
}
}

View File

@@ -4,9 +4,6 @@ import { AuthModule } from '@app/unraid-api/auth/auth.module.js';
import { ApiKeyModule } from '@app/unraid-api/graph/resolvers/api-key/api-key.module.js';
import { ApiKeyResolver } from '@app/unraid-api/graph/resolvers/api-key/api-key.resolver.js';
import { ArrayModule } from '@app/unraid-api/graph/resolvers/array/array.module.js';
import { ArrayMutationsResolver } from '@app/unraid-api/graph/resolvers/array/array.mutations.resolver.js';
import { ArrayResolver } from '@app/unraid-api/graph/resolvers/array/array.resolver.js';
import { ArrayService } from '@app/unraid-api/graph/resolvers/array/array.service.js';
import { CloudResolver } from '@app/unraid-api/graph/resolvers/cloud/cloud.resolver.js';
import { ConfigResolver } from '@app/unraid-api/graph/resolvers/config/config.resolver.js';
import { ConnectModule } from '@app/unraid-api/graph/resolvers/connect/connect.module.js';
@@ -14,6 +11,7 @@ import { CustomizationModule } from '@app/unraid-api/graph/resolvers/customizati
import { DisksModule } from '@app/unraid-api/graph/resolvers/disks/disks.module.js';
import { DisplayResolver } from '@app/unraid-api/graph/resolvers/display/display.resolver.js';
import { DockerModule } from '@app/unraid-api/graph/resolvers/docker/docker.module.js';
import { FlashBackupModule } from '@app/unraid-api/graph/resolvers/flash-backup/flash-backup.module.js';
import { FlashResolver } from '@app/unraid-api/graph/resolvers/flash/flash.resolver.js';
import { InfoResolver } from '@app/unraid-api/graph/resolvers/info/info.resolver.js';
import { LogsResolver } from '@app/unraid-api/graph/resolvers/logs/logs.resolver.js';
@@ -24,6 +22,7 @@ import { NotificationsResolver } from '@app/unraid-api/graph/resolvers/notificat
import { NotificationsService } from '@app/unraid-api/graph/resolvers/notifications/notifications.service.js';
import { OnlineResolver } from '@app/unraid-api/graph/resolvers/online/online.resolver.js';
import { OwnerResolver } from '@app/unraid-api/graph/resolvers/owner/owner.resolver.js';
import { RCloneModule } from '@app/unraid-api/graph/resolvers/rclone/rclone.module.js';
import { RegistrationResolver } from '@app/unraid-api/graph/resolvers/registration/registration.resolver.js';
import { ServerResolver } from '@app/unraid-api/graph/resolvers/servers/server.resolver.js';
import { VarsResolver } from '@app/unraid-api/graph/resolvers/vars/vars.resolver.js';
@@ -35,7 +34,17 @@ import { SharesResolver } from '@app/unraid-api/graph/shares/shares.resolver.js'
import { MeResolver } from '@app/unraid-api/graph/user/user.resolver.js';
@Module({
imports: [ArrayModule, ApiKeyModule, ConnectModule, CustomizationModule, DockerModule, DisksModule],
imports: [
ArrayModule,
ApiKeyModule,
AuthModule,
ConnectModule,
CustomizationModule,
DockerModule,
DisksModule,
FlashBackupModule,
RCloneModule,
],
providers: [
CloudResolver,
ConfigResolver,

View File

@@ -1,16 +1,19 @@
import { Controller, Get, Logger, Param, Res } from '@nestjs/common';
import { Controller, Get, Logger, Param, Res, Req, All } from '@nestjs/common';
import { AuthActionVerb, AuthPossession, UsePermissions } from 'nest-authz';
import type { FastifyReply } from '@app/unraid-api/types/fastify.js';
import type { FastifyReply, FastifyRequest } from '@app/unraid-api/types/fastify.js';
import { Public } from '@app/unraid-api/auth/public.decorator.js';
import { Resource } from '@app/unraid-api/graph/resolvers/base.model.js';
import { RestService } from '@app/unraid-api/rest/rest.service.js';
import got from 'got';
@Controller()
export class RestController {
protected logger = new Logger(RestController.name);
constructor(private readonly restService: RestService) {}
constructor(
private readonly restService: RestService,
) {}
@Get('/')
@Public()
@@ -53,4 +56,45 @@ export class RestController {
return res.status(500).send(`Error: Failed to get customizations`);
}
}
/*
@All('/graphql/api/rclone-webgui/*')
@UsePermissions({
action: AuthActionVerb.READ,
resource: Resource.FLASH,
possession: AuthPossession.ANY,
})
async proxyRcloneWebGui(@Req() req: FastifyRequest, @Res() res: FastifyReply) {
try {
const rcloneDetails = await this.flashBackupService.serveWebGui();
const path = req.url.replace('/graphql/api/rclone-webgui/', '');
const targetUrl = `${rcloneDetails.url}${path}`;
this.logger.debug(`Proxying request to: ${targetUrl}`);
// Forward the request to the RClone service
const method = req.method.toLowerCase();
const options = {
headers: {
...req.headers,
Authorization: `Basic ${Buffer.from(`${rcloneDetails.username}:${rcloneDetails.password}`).toString('base64')}`,
},
body: req.body,
responseType: 'buffer',
enableUnixSockets: true,
};
const response = await got[method](targetUrl, options);
// Forward the response back to the client
return res
.status(response.statusCode)
.headers(response.headers)
.send(response.body);
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
this.logger.error(`Error proxying to RClone WebGUI: ${errorMessage}`);
return res.status(500).send(`Error: Failed to proxy to RClone WebGUI`);
}
}
*/
}

View File

@@ -2,9 +2,9 @@ import { Module } from '@nestjs/common';
import { RestController } from '@app/unraid-api/rest/rest.controller.js';
import { RestService } from '@app/unraid-api/rest/rest.service.js';
import { RCloneModule } from '@app/unraid-api/graph/resolvers/rclone/rclone.module.js';
@Module({
imports: [],
imports: [RCloneModule],
controllers: [RestController],
providers: [RestService],
})

View File

@@ -0,0 +1,216 @@
<script lang="ts" setup>
import { computed, ref } from 'vue';
import { useMutation, useQuery } from '@vue/apollo-composable';
import { BrandButton, jsonFormsRenderers } from '@unraid/ui';
import { JsonForms } from '@jsonforms/vue';
import {
CREATE_REMOTE,
GET_RCLONE_CONFIG_FORM,
LIST_REMOTES,
} from '~/components/RClone/graphql/settings.query';
import { useUnraidApiStore } from '~/store/unraidApi';
import type { RCloneRemote } from '~/composables/gql/graphql';
const { offlineError, unraidApiStatus } = useUnraidApiStore();
// Form state
const formState = ref({
configStep: 0,
showAdvanced: false,
name: '',
type: '',
parameters: {},
});
// Load form schema from API
const { result: formResult, loading: formLoading } = useQuery(GET_RCLONE_CONFIG_FORM);
// Query existing remotes
const { result, refetch: refetchRemotes } = useQuery(LIST_REMOTES);
/**
* Form submission and mutation handling
*/
const {
mutate: createRemote,
loading: isCreating,
error: createError,
onDone: onCreateDone,
} = useMutation(CREATE_REMOTE);
// Handle form submission
const submitForm = async () => {
try {
await createRemote({
input: {
name: formState.value.name,
type: formState.value.type,
parameters: formState.value.parameters,
},
});
} catch (error) {
console.error('Error creating remote:', error);
}
};
// Handle successful creation
onCreateDone(async () => {
// Show success message
if (window.toast) {
window.toast.success('Remote Configuration Created', {
description: `Successfully created remote "${formState.value.name}"`,
});
}
// Reset form and refetch remotes
formState.value = {
configStep: 0,
showAdvanced: false,
name: '',
type: '',
parameters: {},
};
await refetchRemotes();
});
// Set up JSONForms config
const jsonFormsConfig = {
restrict: false,
trim: false,
};
const renderers = [...jsonFormsRenderers];
// Handle form data changes
const onChange = ({ data }: { data: Record<string, unknown> }) => {
formState.value = data as typeof formState.value;
};
// Navigate between form steps
const goToNextStep = () => {
if (formState.value.configStep < 2) {
formState.value.configStep++;
}
};
const goToPreviousStep = () => {
if (formState.value.configStep > 0) {
formState.value.configStep--;
}
};
// Check if form can proceed to next step
const canProceedToNextStep = computed(() => {
if (formState.value.configStep === 0) {
// Step 1: Need name and type
return !!formState.value.name && !!formState.value.type;
}
if (formState.value.configStep === 1) {
// Step 2: Provider-specific validation could go here
return true;
}
return true;
});
// Check if form can be submitted (on last step)
const canSubmitForm = computed(() => {
return formState.value.configStep === 2 && !!formState.value.name && !!formState.value.type;
});
</script>
<template>
<div class="bg-white rounded-lg border border-gray-200 shadow-sm">
<div class="p-6">
<h2 class="text-xl font-medium mb-4">Configure RClone Remote</h2>
<div v-if="createError" class="mb-4 p-4 bg-red-50 border border-red-200 text-red-700 rounded-md">
{{ createError.message }}
</div>
<div v-if="formLoading" class="py-8 text-center text-gray-500">Loading configuration form...</div>
<!-- Form -->
<div v-else-if="formResult?.rcloneBackup?.configForm" class="mt-6 [&_.vertical-layout]:space-y-6">
<JsonForms
v-if="formResult?.rcloneBackup?.configForm"
:schema="formResult.rcloneBackup.configForm.dataSchema"
:uischema="formResult.rcloneBackup.configForm.uiSchema"
:renderers="renderers"
:data="formState"
:config="jsonFormsConfig"
:readonly="isCreating"
@change="onChange"
/>
<!-- Form navigation buttons -->
<div class="mt-6 flex justify-between">
<BrandButton
v-if="formState.configStep > 0"
variant="outline"
padding="lean"
size="12px"
class="leading-normal"
:disabled="isCreating"
@click="goToPreviousStep"
>
Previous
</BrandButton>
<div class="flex space-x-4 ml-auto">
<BrandButton
v-if="formState.configStep < 2"
variant="outline-primary"
padding="lean"
size="12px"
class="leading-normal"
:disabled="!canProceedToNextStep || isCreating"
@click="goToNextStep"
>
Next
</BrandButton>
<BrandButton
v-if="formState.configStep === 2"
variant="fill"
padding="lean"
size="12px"
class="leading-normal"
:disabled="!canSubmitForm || isCreating"
@click="submitForm"
>
Create Remote
</BrandButton>
</div>
</div>
</div>
<!-- Existing remotes list -->
<div v-if="existingRemotes?.length > 0" class="mt-10">
<h3 class="text-lg font-medium mb-4">Configured Remotes</h3>
<div class="space-y-4">
<div
v-for="remote in existingRemotes"
:key="remote.name"
class="p-4 border border-gray-200 rounded-md"
>
<div class="flex justify-between items-center">
<div>
<h4 class="font-medium">{{ remote.name }}</h4>
<div class="text-sm text-gray-500">Type: {{ remote.type }}</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style lang="postcss">
/* Import unraid-ui globals first */
@import '@unraid/ui/styles';
</style>

View File

@@ -0,0 +1,35 @@
import { graphql } from "~/composables/gql/gql";
export const GET_RCLONE_CONFIG_FORM = graphql(/* GraphQL */ `
query GetRCloneConfigForm {
rcloneBackup {
configForm {
dataSchema
uiSchema
}
drives {
name
options
}
}
}
`);
export const LIST_REMOTES = graphql(/* GraphQL */ `
query ListRCloneRemotes {
rcloneBackup {
remotes
}
}
`);
export const CREATE_REMOTE = graphql(/* GraphQL */ `
mutation CreateRCloneRemote($input: CreateRCloneRemoteInput!) {
createRCloneRemote(input: $input) {
name
type
config
}
}
`);

View File

@@ -36,6 +36,9 @@ type Documents = {
"\n mutation RecomputeOverview {\n recalculateOverview {\n archive {\n ...NotificationCountFragment\n }\n unread {\n ...NotificationCountFragment\n }\n }\n }\n": typeof types.RecomputeOverviewDocument,
"\n subscription NotificationAddedSub {\n notificationAdded {\n ...NotificationFragment\n }\n }\n": typeof types.NotificationAddedSubDocument,
"\n subscription NotificationOverviewSub {\n notificationsOverview {\n archive {\n ...NotificationCountFragment\n }\n unread {\n ...NotificationCountFragment\n }\n }\n }\n": typeof types.NotificationOverviewSubDocument,
"\n query GetRCloneConfigForm {\n rcloneBackup {\n configForm { \n dataSchema\n uiSchema\n }\n drives {\n name\n options\n }\n }\n }\n": typeof types.GetRCloneConfigFormDocument,
"\n query ListRCloneRemotes {\n rcloneBackup {\n remotes\n }\n }\n": typeof types.ListRCloneRemotesDocument,
"\n mutation CreateRCloneRemote($input: CreateRCloneRemoteInput!) {\n createRCloneRemote(input: $input) {\n name\n type\n config\n }\n }\n": typeof types.CreateRCloneRemoteDocument,
"\n mutation ConnectSignIn($input: ConnectSignInInput!) {\n connectSignIn(input: $input)\n }\n": typeof types.ConnectSignInDocument,
"\n mutation SignOut {\n connectSignOut\n }\n": typeof types.SignOutDocument,
"\n fragment PartialCloud on Cloud {\n error\n apiKey {\n valid\n error\n }\n cloud {\n status\n error\n }\n minigraphql {\n status\n error\n }\n relay {\n status\n error\n }\n }\n": typeof types.PartialCloudFragmentDoc,
@@ -69,6 +72,9 @@ const documents: Documents = {
"\n mutation RecomputeOverview {\n recalculateOverview {\n archive {\n ...NotificationCountFragment\n }\n unread {\n ...NotificationCountFragment\n }\n }\n }\n": types.RecomputeOverviewDocument,
"\n subscription NotificationAddedSub {\n notificationAdded {\n ...NotificationFragment\n }\n }\n": types.NotificationAddedSubDocument,
"\n subscription NotificationOverviewSub {\n notificationsOverview {\n archive {\n ...NotificationCountFragment\n }\n unread {\n ...NotificationCountFragment\n }\n }\n }\n": types.NotificationOverviewSubDocument,
"\n query GetRCloneConfigForm {\n rcloneBackup {\n configForm { \n dataSchema\n uiSchema\n }\n drives {\n name\n options\n }\n }\n }\n": types.GetRCloneConfigFormDocument,
"\n query ListRCloneRemotes {\n rcloneBackup {\n remotes\n }\n }\n": types.ListRCloneRemotesDocument,
"\n mutation CreateRCloneRemote($input: CreateRCloneRemoteInput!) {\n createRCloneRemote(input: $input) {\n name\n type\n config\n }\n }\n": types.CreateRCloneRemoteDocument,
"\n mutation ConnectSignIn($input: ConnectSignInInput!) {\n connectSignIn(input: $input)\n }\n": types.ConnectSignInDocument,
"\n mutation SignOut {\n connectSignOut\n }\n": types.SignOutDocument,
"\n fragment PartialCloud on Cloud {\n error\n apiKey {\n valid\n error\n }\n cloud {\n status\n error\n }\n minigraphql {\n status\n error\n }\n relay {\n status\n error\n }\n }\n": types.PartialCloudFragmentDoc,
@@ -182,6 +188,18 @@ export function graphql(source: "\n subscription NotificationAddedSub {\n no
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n subscription NotificationOverviewSub {\n notificationsOverview {\n archive {\n ...NotificationCountFragment\n }\n unread {\n ...NotificationCountFragment\n }\n }\n }\n"): (typeof documents)["\n subscription NotificationOverviewSub {\n notificationsOverview {\n archive {\n ...NotificationCountFragment\n }\n unread {\n ...NotificationCountFragment\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n query GetRCloneConfigForm {\n rcloneBackup {\n configForm { \n dataSchema\n uiSchema\n }\n drives {\n name\n options\n }\n }\n }\n"): (typeof documents)["\n query GetRCloneConfigForm {\n rcloneBackup {\n configForm { \n dataSchema\n uiSchema\n }\n drives {\n name\n options\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n query ListRCloneRemotes {\n rcloneBackup {\n remotes\n }\n }\n"): (typeof documents)["\n query ListRCloneRemotes {\n rcloneBackup {\n remotes\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n mutation CreateRCloneRemote($input: CreateRCloneRemoteInput!) {\n createRCloneRemote(input: $input) {\n name\n type\n config\n }\n }\n"): (typeof documents)["\n mutation CreateRCloneRemote($input: CreateRCloneRemoteInput!) {\n createRCloneRemote(input: $input) {\n name\n type\n config\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/

View File

@@ -20,8 +20,6 @@ export type Scalars = {
DateTime: { input: string; output: string; }
/** The `JSON` scalar type represents JSON values as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf). */
JSON: { input: any; output: any; }
/** The `JSONObject` scalar type represents JSON objects as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf). */
JSONObject: { input: any; output: any; }
/** A field whose value is a valid TCP port within the range of 0 to 65535: https://en.wikipedia.org/wiki/Transmission_Control_Protocol#TCP_ports */
Port: { input: number; output: number; }
/**
@@ -527,6 +525,12 @@ export type CreateApiKeyInput = {
roles?: InputMaybe<Array<Role>>;
};
export type CreateRCloneRemoteInput = {
config: Scalars['JSON']['input'];
name: Scalars['String']['input'];
type: Scalars['String']['input'];
};
export type Customization = {
__typename?: 'Customization';
activationCode?: Maybe<ActivationCode>;
@@ -673,10 +677,10 @@ export type DockerContainer = Node & {
id: Scalars['PrefixedID']['output'];
image: Scalars['String']['output'];
imageId: Scalars['String']['output'];
labels?: Maybe<Scalars['JSONObject']['output']>;
mounts?: Maybe<Array<Scalars['JSONObject']['output']>>;
labels?: Maybe<Scalars['JSON']['output']>;
mounts?: Maybe<Array<Scalars['JSON']['output']>>;
names: Array<Scalars['String']['output']>;
networkSettings?: Maybe<Scalars['JSONObject']['output']>;
networkSettings?: Maybe<Scalars['JSON']['output']>;
ports: Array<ContainerPort>;
/** Total size of all the files in the container */
sizeRootFs?: Maybe<Scalars['Int']['output']>;
@@ -705,19 +709,19 @@ export type DockerMutationsStopArgs = {
export type DockerNetwork = Node & {
__typename?: 'DockerNetwork';
attachable: Scalars['Boolean']['output'];
configFrom: Scalars['JSONObject']['output'];
configFrom: Scalars['JSON']['output'];
configOnly: Scalars['Boolean']['output'];
containers: Scalars['JSONObject']['output'];
containers: Scalars['JSON']['output'];
created: Scalars['String']['output'];
driver: Scalars['String']['output'];
enableIPv6: Scalars['Boolean']['output'];
id: Scalars['PrefixedID']['output'];
ingress: Scalars['Boolean']['output'];
internal: Scalars['Boolean']['output'];
ipam: Scalars['JSONObject']['output'];
labels: Scalars['JSONObject']['output'];
ipam: Scalars['JSON']['output'];
labels: Scalars['JSON']['output'];
name: Scalars['String']['output'];
options: Scalars['JSONObject']['output'];
options: Scalars['JSON']['output'];
scope: Scalars['String']['output'];
};
@@ -752,6 +756,14 @@ export type Flash = Node & {
vendor: Scalars['String']['output'];
};
export type FlashBackupStatus = {
__typename?: 'FlashBackupStatus';
/** Job ID if available, can be used to check job status. */
jobId?: Maybe<Scalars['String']['output']>;
/** Status message indicating the outcome of the backup initiation. */
status: Scalars['String']['output'];
};
export type Gpu = Node & {
__typename?: 'Gpu';
blacklisted: Scalars['Boolean']['output'];
@@ -828,6 +840,17 @@ export type InfoMemory = Node & {
used: Scalars['BigInt']['output'];
};
export type InitiateFlashBackupInput = {
/** Destination path on the remote. */
destinationPath: Scalars['String']['input'];
/** Additional options for the backup operation, such as --dry-run or --transfers. */
options?: InputMaybe<Scalars['JSON']['input']>;
/** The name of the remote configuration to use for the backup. */
remoteName: Scalars['String']['input'];
/** Source path to backup (typically the flash drive). */
sourcePath: Scalars['String']['input'];
};
export type KeyFile = {
__typename?: 'KeyFile';
contents?: Maybe<Scalars['String']['output']>;
@@ -901,11 +924,14 @@ export type Mutation = {
connectSignOut: Scalars['Boolean']['output'];
/** Creates a new notification record */
createNotification: Notification;
createRCloneRemote: RCloneRemote;
/** Deletes all archived notifications on server. */
deleteArchivedNotifications: NotificationOverview;
deleteNotification: NotificationOverview;
docker: DockerMutations;
enableDynamicRemoteAccess: Scalars['Boolean']['output'];
/** Initiates a flash drive backup using a configured remote. */
initiateFlashBackup: FlashBackupStatus;
parityCheck: ParityCheckMutations;
/** Reads each notification to recompute & update the overview. */
recalculateOverview: NotificationOverview;
@@ -946,6 +972,11 @@ export type MutationCreateNotificationArgs = {
};
export type MutationCreateRCloneRemoteArgs = {
input: CreateRCloneRemoteInput;
};
export type MutationDeleteNotificationArgs = {
id: Scalars['PrefixedID']['input'];
type: NotificationType;
@@ -957,6 +988,16 @@ export type MutationEnableDynamicRemoteAccessArgs = {
};
export type MutationInitiateFlashBackupArgs = {
input: InitiateFlashBackupInput;
};
export type MutationRemoveRoleFromApiKeyArgs = {
input: RemoveRoleFromApiKeyInput;
};
export type MutationSetAdditionalAllowedOriginsArgs = {
input: AllowedOriginInput;
};
@@ -1199,6 +1240,7 @@ export type Query = {
parityHistory: Array<ParityCheck>;
publicPartnerInfo?: Maybe<PublicPartnerInfo>;
publicTheme: Theme;
rcloneBackup: RCloneBackupSettings;
registration?: Maybe<Registration>;
remoteAccess: RemoteAccess;
server?: Maybe<Server>;
@@ -1227,6 +1269,61 @@ export type QueryLogFileArgs = {
startLine?: InputMaybe<Scalars['Int']['input']>;
};
export type RCloneBackupConfigForm = {
__typename?: 'RCloneBackupConfigForm';
dataSchema: Scalars['JSON']['output'];
id: Scalars['ID']['output'];
uiSchema: Scalars['JSON']['output'];
};
export type RCloneBackupSettings = {
__typename?: 'RCloneBackupSettings';
configForm: RCloneBackupConfigForm;
drives: Array<RCloneDrive>;
remotes: Array<Scalars['String']['output']>;
};
export type RCloneDrive = {
__typename?: 'RCloneDrive';
/** Provider name */
name: Scalars['String']['output'];
/** Provider options and configuration schema */
options: Scalars['JSON']['output'];
};
export type RCloneProviderOption = {
__typename?: 'RCloneProviderOption';
advanced?: Maybe<Scalars['Boolean']['output']>;
default?: Maybe<Scalars['JSON']['output']>;
defaultStr?: Maybe<Scalars['String']['output']>;
examples?: Maybe<Array<RCloneProviderOptionExample>>;
help: Scalars['String']['output'];
hide?: Maybe<Scalars['Boolean']['output']>;
isPassword?: Maybe<Scalars['Boolean']['output']>;
name: Scalars['String']['output'];
noPrefix?: Maybe<Scalars['Boolean']['output']>;
provider: Scalars['String']['output'];
required?: Maybe<Scalars['Boolean']['output']>;
shortOpt?: Maybe<Scalars['String']['output']>;
type?: Maybe<Scalars['String']['output']>;
value?: Maybe<Scalars['JSON']['output']>;
valueStr?: Maybe<Scalars['String']['output']>;
};
export type RCloneProviderOptionExample = {
__typename?: 'RCloneProviderOptionExample';
help: Scalars['String']['output'];
provider: Scalars['String']['output'];
value: Scalars['String']['output'];
};
export type RCloneRemote = {
__typename?: 'RCloneRemote';
config: Scalars['JSON']['output'];
name: Scalars['String']['output'];
type: Scalars['String']['output'];
};
export type Registration = Node & {
__typename?: 'Registration';
expiration?: Maybe<Scalars['String']['output']>;
@@ -1961,6 +2058,23 @@ export type NotificationOverviewSubSubscription = { __typename?: 'Subscription',
& { ' $fragmentRefs'?: { 'NotificationCountFragmentFragment': NotificationCountFragmentFragment } }
) } };
export type GetRCloneConfigFormQueryVariables = Exact<{ [key: string]: never; }>;
export type GetRCloneConfigFormQuery = { __typename?: 'Query', rcloneBackup: { __typename?: 'RCloneBackupSettings', configForm: { __typename?: 'RCloneBackupConfigForm', dataSchema: any, uiSchema: any }, drives: Array<{ __typename?: 'RCloneDrive', name: string, options: any }> } };
export type ListRCloneRemotesQueryVariables = Exact<{ [key: string]: never; }>;
export type ListRCloneRemotesQuery = { __typename?: 'Query', rcloneBackup: { __typename?: 'RCloneBackupSettings', remotes: Array<string> } };
export type CreateRCloneRemoteMutationVariables = Exact<{
input: CreateRCloneRemoteInput;
}>;
export type CreateRCloneRemoteMutation = { __typename?: 'Mutation', createRCloneRemote: { __typename?: 'RCloneRemote', name: string, type: string, config: any } };
export type ConnectSignInMutationVariables = Exact<{
input: ConnectSignInInput;
}>;
@@ -2035,6 +2149,9 @@ export const OverviewDocument = {"kind":"Document","definitions":[{"kind":"Opera
export const RecomputeOverviewDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"RecomputeOverview"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"recalculateOverview"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"archive"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"NotificationCountFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"unread"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"NotificationCountFragment"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"NotificationCountFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"NotificationCounts"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"total"}},{"kind":"Field","name":{"kind":"Name","value":"info"}},{"kind":"Field","name":{"kind":"Name","value":"warning"}},{"kind":"Field","name":{"kind":"Name","value":"alert"}}]}}]} as unknown as DocumentNode<RecomputeOverviewMutation, RecomputeOverviewMutationVariables>;
export const NotificationAddedSubDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"subscription","name":{"kind":"Name","value":"NotificationAddedSub"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"notificationAdded"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"NotificationFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"NotificationFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Notification"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"subject"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"importance"}},{"kind":"Field","name":{"kind":"Name","value":"link"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"timestamp"}},{"kind":"Field","name":{"kind":"Name","value":"formattedTimestamp"}}]}}]} as unknown as DocumentNode<NotificationAddedSubSubscription, NotificationAddedSubSubscriptionVariables>;
export const NotificationOverviewSubDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"subscription","name":{"kind":"Name","value":"NotificationOverviewSub"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"notificationsOverview"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"archive"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"NotificationCountFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"unread"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"NotificationCountFragment"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"NotificationCountFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"NotificationCounts"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"total"}},{"kind":"Field","name":{"kind":"Name","value":"info"}},{"kind":"Field","name":{"kind":"Name","value":"warning"}},{"kind":"Field","name":{"kind":"Name","value":"alert"}}]}}]} as unknown as DocumentNode<NotificationOverviewSubSubscription, NotificationOverviewSubSubscriptionVariables>;
export const GetRCloneConfigFormDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetRCloneConfigForm"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"rcloneBackup"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configForm"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"dataSchema"}},{"kind":"Field","name":{"kind":"Name","value":"uiSchema"}}]}},{"kind":"Field","name":{"kind":"Name","value":"drives"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"options"}}]}}]}}]}}]} as unknown as DocumentNode<GetRCloneConfigFormQuery, GetRCloneConfigFormQueryVariables>;
export const ListRCloneRemotesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ListRCloneRemotes"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"rcloneBackup"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"remotes"}}]}}]}}]} as unknown as DocumentNode<ListRCloneRemotesQuery, ListRCloneRemotesQueryVariables>;
export const CreateRCloneRemoteDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateRCloneRemote"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"CreateRCloneRemoteInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createRCloneRemote"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"config"}}]}}]}}]} as unknown as DocumentNode<CreateRCloneRemoteMutation, CreateRCloneRemoteMutationVariables>;
export const ConnectSignInDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"ConnectSignIn"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ConnectSignInInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"connectSignIn"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}]}]}}]} as unknown as DocumentNode<ConnectSignInMutation, ConnectSignInMutationVariables>;
export const SignOutDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"SignOut"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"connectSignOut"}}]}}]} as unknown as DocumentNode<SignOutMutation, SignOutMutationVariables>;
export const ServerStateDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"serverState"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"cloud"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"PartialCloud"}}]}},{"kind":"Field","name":{"kind":"Name","value":"config"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"error"}},{"kind":"Field","name":{"kind":"Name","value":"valid"}}]}},{"kind":"Field","name":{"kind":"Name","value":"info"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"os"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hostname"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"owner"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"avatar"}},{"kind":"Field","name":{"kind":"Name","value":"username"}}]}},{"kind":"Field","name":{"kind":"Name","value":"registration"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"state"}},{"kind":"Field","name":{"kind":"Name","value":"expiration"}},{"kind":"Field","name":{"kind":"Name","value":"keyFile"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"contents"}}]}},{"kind":"Field","name":{"kind":"Name","value":"updateExpiration"}}]}},{"kind":"Field","name":{"kind":"Name","value":"vars"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"regGen"}},{"kind":"Field","name":{"kind":"Name","value":"regState"}},{"kind":"Field","name":{"kind":"Name","value":"configError"}},{"kind":"Field","name":{"kind":"Name","value":"configValid"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"PartialCloud"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Cloud"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"error"}},{"kind":"Field","name":{"kind":"Name","value":"apiKey"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"valid"}},{"kind":"Field","name":{"kind":"Name","value":"error"}}]}},{"kind":"Field","name":{"kind":"Name","value":"cloud"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"error"}}]}},{"kind":"Field","name":{"kind":"Name","value":"minigraphql"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"error"}}]}},{"kind":"Field","name":{"kind":"Name","value":"relay"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"error"}}]}}]}}]} as unknown as DocumentNode<ServerStateQuery, ServerStateQueryVariables>;

View File

@@ -74,6 +74,7 @@ const splitLinks = split(
wsLink,
httpLink
);
/**
* @todo as we add retries, determine which we'll need
* https://www.apollographql.com/docs/react/api/link/introduction/#additive-composition

24
web/pages/flashbackup.vue Normal file
View File

@@ -0,0 +1,24 @@
<script setup>
import RCloneConfig from '~/components/RClone/RCloneConfig.vue';
import { useDummyServerStore } from '~/_data/serverState';
const { registerEntry } = useCustomElements();
const serverStore = useDummyServerStore();
const { serverState } = storeToRefs(serverStore);
onBeforeMount(() => {
registerEntry('UnraidComponents');
});
onMounted(() => {
document.cookie = 'unraid_session_cookie=mockusersession';
});
</script>
<template>
<div>
{{ serverState.connectPluginInstalled }}
<RCloneConfig />
</div>
</template>