feat: ui working for RClone setup

This commit is contained in:
Eli Bosley
2025-04-15 19:20:23 -04:00
parent f93c850b95
commit 242697c8d8
15 changed files with 528 additions and 589 deletions

View File

@@ -1353,34 +1353,12 @@ type RCloneDrive {
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(providerType: String, parameters: JSON): JSON!
uiSchema(providerType: String, parameters: JSON): JSON!
dataSchema: JSON!
uiSchema: JSON!
providerType: String
parameters: JSON
}
type RCloneBackupSettings {
@@ -1392,7 +1370,7 @@ type RCloneBackupSettings {
type RCloneRemote {
name: String!
type: String!
config: JSON!
parameters: JSON!
}
type Flash implements Node {

View File

@@ -1,38 +1,14 @@
import { RuleEffect, type SchemaBasedCondition, type JsonSchema, JsonSchema7 } from '@jsonforms/core';
import type { SchemaBasedCondition } from '@jsonforms/core';
import { JsonSchema7, RuleEffect } from '@jsonforms/core';
import type { DataSlice, SettingSlice, UIElement } from '@app/unraid-api/types/json-forms.js';
import { RCloneProviderOptionResponse } from '@app/unraid-api/graph/resolvers/rclone/rclone.model.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 {
function translateRCloneOptionToJsonSchema(option: RCloneProviderOptionResponse): JsonSchema7 {
const schema: JsonSchema7 = {
type: getJsonSchemaType(option.Type || 'string'),
title: option.Name,
@@ -41,64 +17,85 @@ function translateRCloneOptionToJsonSchema(option: RCloneOptionDef): JsonSchema7
// Add default value if available
if (option.Default !== undefined && option.Default !== '') {
schema.default = option.Default;
// RClone uses 'off' for SizeSuffix/Duration defaults sometimes
if ((option.Type === 'SizeSuffix' || option.Type === 'Duration') && option.Default === 'off') {
schema.default = 'off';
} else if (schema.type === 'number' && typeof option.Default === 'number') {
schema.default = option.Default;
} else if (schema.type === 'integer' && Number.isInteger(option.Default)) {
schema.default = option.Default;
} else if (schema.type === 'boolean' && typeof option.Default === 'boolean') {
schema.default = option.Default;
} else if (schema.type === 'string') {
// Ensure default is a string if the type is string
schema.default = String(option.Default);
}
// If type doesn't match, we might skip the default or log a warning
}
// Add enum values if available
if (option.Options && option.Options.length > 0) {
schema.enum = option.Options;
// Add enum values if available (used for dropdowns)
if (option.Examples && option.Examples.length > 0) {
schema.enum = option.Examples.map((example) => example.Value);
}
// Add validation constraints
// Add format hints
const format = getJsonFormElementForType(
option.Type,
option.Examples?.map((example) => example.Value),
option.IsPassword
);
if (format && format !== schema.type) {
// Don't add format if it's just the type (e.g., 'number')
schema.format = format;
}
// Add validation constraints and patterns
if (option.Required) {
// Make '0' valid for required number/integer fields unless explicitly disallowed
if (schema.type === 'string') {
schema.minLength = 1;
} else if (schema.type === 'number') {
schema.minimum = 0;
}
// Note: 'required' is usually handled at the object level in JSON Schema,
// but minLength/minimum provide basic non-empty checks.
}
// Specific type-based validation
switch (option.Type?.toLowerCase()) {
case 'int':
// Handled by type: 'integer' in getJsonSchemaType
break;
case 'sizesuffix':
// Pattern allows 'off' or digits followed by optional size units (K, M, G, T, P) and optional iB/B
// Allows multiple concatenated values like 1G100M
schema.pattern = '^(off|(\\d+([KMGTPE]i?B?)?)+)$';
schema.errorMessage = 'Invalid size format. Examples: "10G", "100M", "1.5GiB", "off".';
break;
case 'duration':
// Pattern allows 'off' or digits (with optional decimal) followed by time units (ns, us, ms, s, m, h)
// Allows multiple concatenated values like 1h15m
schema.pattern = '^(off|(\\d+(\\.\\d+)?(ns|us|\\u00b5s|ms|s|m|h))+)$'; // \u00b5s is µs
schema.errorMessage =
'Invalid duration format. Examples: "10s", "1.5m", "100ms", "1h15m", "off".';
break;
}
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> = {}
providerOptions: Record<string, RCloneProviderOptionResponse[]> = {}
): SettingSlice {
// If provider types not provided, get from config
if (providerTypes.length === 0) {
providerTypes = getAvailableProviderTypes();
}
// Combine all form slices for the complete schema
const options = providerOptions[selectedProvider];
const slices = [
getBasicConfigSlice(providerTypes),
getProviderConfigSlice(selectedProvider, providerOptions),
getAdvancedConfigSlice(selectedProvider, providerOptions),
getProviderConfigSlice(selectedProvider, options, false), // Standard options
getProviderConfigSlice(selectedProvider, options, true), // Advanced options
];
return mergeSettingSlices(slices);
@@ -116,6 +113,7 @@ function getBasicConfigSlice(providerTypes: string[]): SettingSlice {
label: 'Name of this remote (For your reference)',
options: {
placeholder: 'Enter a name',
format: 'string',
},
},
{
@@ -123,7 +121,6 @@ function getBasicConfigSlice(providerTypes: string[]): SettingSlice {
scope: '#/properties/type',
label: 'Storage Provider Type',
options: {
format: 'dropdown',
description: 'Select the cloud storage provider to use for this remote.',
},
},
@@ -132,7 +129,8 @@ function getBasicConfigSlice(providerTypes: string[]): SettingSlice {
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>.',
description:
'For more information, refer to the <a href="https://rclone.org/commands/rclone_config/" target="_blank">RClone Config Documentation</a>.',
},
},
];
@@ -151,7 +149,7 @@ function getBasicConfigSlice(providerTypes: string[]): SettingSlice {
type: 'string',
title: 'Provider Type',
default: providerTypes.length > 0 ? providerTypes[0] : '',
oneOf: providerTypes.map(type => ({ const: type, title: type }))
enum: providerTypes,
},
};
@@ -162,74 +160,82 @@ function getBasicConfigSlice(providerTypes: string[]): SettingSlice {
}
/**
* Step 2: Provider-specific configuration based on the selected provider
* Step 2/3: Provider-specific configuration based on the selected provider and whether to show advanced options
*
* @param selectedProvider - The selected provider type
* @param providerOptions - The provider options for the selected provider
*/
function getProviderConfigSlice(
selectedProvider: string,
providerOptions: Record<string, RCloneOptionDef>
providerOptions: RCloneProviderOptionResponse[],
showAdvancedOptions: boolean = false
): SettingSlice {
// Default elements for when a provider isn't selected or options aren't loaded
let providerConfigElements: UIElement[] = [
let configElements: UIElement[] = [
{
type: 'Label',
text: 'Provider Configuration',
text: `${showAdvancedOptions ? 'Advanced' : 'Provider'} Configuration`,
options: {
format: 'loading',
description: 'Select a provider type first to see provider-specific options.',
format: 'loading', // Or 'note' if preferred
description: `Select a provider type first to see ${showAdvancedOptions ? 'advanced' : 'standard'} options.`,
},
},
];
// Default properties when no provider is selected
let providerConfigProperties: Record<string, JsonSchema7> = {};
let configProperties: 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
}
if (!selectedProvider || providerOptions.length === 0) {
return {
properties: configProperties as unknown as DataSlice,
elements: [],
};
}
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.',
},
},
];
// Filter options based on the showAdvancedOptions flag
const filteredOptions = providerOptions.filter((option) => {
if (showAdvancedOptions && option.Advanced === true) {
return true;
} else if (!showAdvancedOptions && option.Advanced !== true) {
return true;
}
return false;
});
// 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
}
// Create dynamic UI elements based on filtered provider options
const elements = filteredOptions.map<UIElement>((option) => {
return {
type: 'Control',
scope: `#/properties/parameters/properties/${option.Name}`,
label: option.Help || option.Name, // Use Help as primary label if available
options: {
placeholder: option.Default?.toString() || '',
description: option.Help || '', // Redundant? Keep for potential differences
required: option.Required || false,
format: getJsonFormElementForType(
option.Type,
option.Examples?.map((example) => example.Value),
option.IsPassword
),
hide: option.Hide === 1,
},
};
});
paramProperties[key] = translateRCloneOptionToJsonSchema(option);
});
// Create dynamic properties schema based on filtered provider options
const paramProperties: Record<string, JsonSchema7> = {};
filteredOptions.forEach((option) => {
if (option) {
// Ensure option exists before translating
paramProperties[option.Name] = translateRCloneOptionToJsonSchema(option);
}
});
providerConfigProperties = {
console.log('paramProperties', paramProperties);
// Only add parameters object if there are properties
if (Object.keys(paramProperties).length > 0) {
configProperties = {
parameters: {
type: 'object',
properties: paramProperties,
@@ -238,85 +244,8 @@ function getProviderConfigSlice(
}
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,
properties: configProperties,
elements,
};
}
@@ -326,14 +255,21 @@ function getAdvancedConfigSlice(
function getJsonSchemaType(rcloneType: string): string {
switch (rcloneType?.toLowerCase()) {
case 'int':
case 'size':
case 'duration':
return 'integer'; // Use 'integer' for whole numbers
case 'size': // Assuming 'size' might imply large numbers, but 'number' is safer if decimals possible
case 'number': // If rclone explicitly uses 'number'
return 'number';
case 'sizesuffix':
case 'duration':
// Represent these as strings, validation handled by pattern/format
return 'string';
case 'bool':
return 'boolean';
case 'string':
case 'text':
case 'text': // Treat 'text' (multi-line) as 'string' in schema type
case 'password': // Passwords are strings
default:
// Default to string if type is unknown or not provided
return 'string';
}
}
@@ -341,25 +277,37 @@ function getJsonSchemaType(rcloneType: string): string {
/**
* Helper function to get the appropriate UI format based on RClone option type
*/
function getFormatForType(rcloneType: string = '', options: string[] | null = null): string {
function getJsonFormElementForType(
rcloneType: string = '',
options: string[] | null = null,
isPassword: boolean = false
): string | undefined {
if (isPassword) {
return 'password';
}
if (options && options.length > 0) {
return 'dropdown';
return 'dropdown'; // Use enum for dropdowns
}
switch (rcloneType?.toLowerCase()) {
case 'int':
return 'number'; // Use NumberField
case 'size':
return 'number';
return 'number'; // Use NumberField
case 'sizesuffix':
return undefined; // Use default InputField (via isStringControl)
case 'duration':
return 'duration';
return undefined; // Use default InputField (via isStringControl)
case 'bool':
return 'checkbox';
case 'password':
return 'password';
return 'checkbox'; // Matches Switch.vue if toggle=true, else default bool render
case 'text':
return 'textarea';
// Consider 'textarea' format later if needed
return undefined; // Use default InputField (via isStringControl)
case 'password':
return 'password'; // Explicit format for password managers etc.
case 'string':
default:
return 'text';
return undefined; // Use default InputField (via isStringControl)
}
}
@@ -373,7 +321,8 @@ export function getRcloneConfigSlice(): SettingSlice {
text: 'Configure RClone Backup',
options: {
format: 'title',
description: 'This 3-step process will guide you through setting up your RClone backup configuration.',
description:
'This 3-step process will guide you through setting up your RClone backup configuration.',
},
},
{
@@ -400,7 +349,7 @@ export function getRcloneConfigSlice(): SettingSlice {
effect: RuleEffect.SHOW,
condition: {
scope: '#/properties/configStep',
schema: { enum: [1] } // Only show on step 2
schema: { enum: [1] }, // Only show on step 2
} as SchemaBasedCondition,
},
},

View File

@@ -4,9 +4,18 @@ 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';
import { RCloneProvider, RCloneProviderOption, RCloneProviderOptionExample, RCloneProviderOptionResponse, RCloneProviderResponse, RCloneProviderTypes } from '@app/unraid-api/graph/resolvers/rclone/rclone.model.js';
@Injectable()
export class RCloneApiService implements OnModuleInit, OnModuleDestroy {
@@ -103,23 +112,9 @@ export class RCloneApiService implements OnModuleInit, OnModuleDestroy {
/**
* Get providers supported by RClone
*/
async getProviders(): Promise<RCloneProvider[]> {
async getProviders(): Promise<RCloneProviderResponse[]> {
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 };
return response?.providers || [];
}
/**
@@ -269,4 +264,4 @@ export class RCloneApiService implements OnModuleInit, OnModuleDestroy {
password: this.rclonePassword,
};
}
}
}

View File

@@ -1,140 +0,0 @@
import { Args, Context, 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,
@Args('providerType', { nullable: true }) argProviderType?: string,
@Args('parameters', { type: () => GraphQLJSON, nullable: true }) argParameters?: Record<string, unknown>,
@Context() context?: any
): Promise<{ properties: DataSlice; type: 'object' }> {
try {
// Get providerType and parameters from parent query if not provided directly
let providerType = argProviderType || '';
let parameters = argParameters || {};
// Check for these values in the parent query context
if (context?.variableValues) {
providerType = providerType || context.variableValues.providerType || '';
parameters = parameters || context.variableValues.parameters || {};
}
this.logger.debug(`dataSchema using providerType: ${providerType}, parameters: ${JSON.stringify(parameters)}`);
// Get provided types
let providerTypes: string[] = [];
try {
const providers = await this.rcloneApiService.getProviderTypes();
providerTypes = providers.types;
} catch (error) {
this.logger.warn(`Could not get provider types for dataSchema: ${error}`);
}
// Get the schema with provider information
const schema = await this.rcloneFormService.dataSchema(
providerTypes,
providerType,
parameters
);
return {
properties: schema.properties as DataSlice,
type: 'object',
};
} catch (error) {
this.logger.error(`Error generating dataSchema: ${error}`);
return {
properties: {} as DataSlice,
type: 'object',
};
}
}
@ResolveField(() => GraphQLJSON)
async uiSchema(
@Parent() _parent: RCloneBackupConfigForm,
@Args('providerType', { nullable: true }) argProviderType?: string,
@Args('parameters', { type: () => GraphQLJSON, nullable: true }) argParameters?: Record<string, unknown>,
@Context() context?: any
): Promise<Layout> {
try {
// Get providerType and parameters from parent query if not provided directly
let providerType = argProviderType || '';
let parameters = argParameters || {};
// Check for these values in the parent query context
if (context?.variableValues) {
providerType = providerType || context.variableValues.providerType || '';
parameters = parameters || context.variableValues.parameters || {};
}
this.logger.debug(`uiSchema using providerType: ${providerType}, parameters: ${JSON.stringify(parameters)}`);
// Get provided types
let providerTypes: string[] = [];
try {
const providers = await this.rcloneApiService.getProviderTypes();
providerTypes = providers.types;
} catch (error) {
this.logger.warn(`Could not get provider types for uiSchema: ${error}`);
}
return this.rcloneFormService.uiSchema(
providerTypes,
providerType,
parameters
);
} catch (error) {
this.logger.error(`Error generating uiSchema: ${error}`);
return {
type: 'VerticalLayout',
elements: [],
};
}
}
@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

@@ -1,28 +1,45 @@
import { Injectable, Logger } from '@nestjs/common';
import { type Layout } from '@jsonforms/core';
import type { SettingSlice } from '@app/unraid-api/types/json-forms.js';
import { RCloneProviderOptionResponse, RCloneProviderResponse } from '@app/unraid-api/graph/resolvers/rclone/rclone.model.js';
import { mergeSettingSlices } from '@app/unraid-api/types/json-forms.js';
import {
getRcloneConfigFormSchema,
getRcloneConfigSlice,
getAvailableProviderTypes,
} from './jsonforms/rclone-jsonforms-config.js';
import { getRcloneConfigFormSchema, getRcloneConfigSlice } from './jsonforms/rclone-jsonforms-config.js';
import { RCloneApiService } from './rclone-api.service.js';
/**
* Field metadata interface for form generation
*/
interface FieldMetadata {
name: string;
label: string;
type: string;
required: boolean;
default: any;
examples: any[];
options: string[];
validationType: string;
}
/**
* 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> = {};
private providerNames: string[] = [];
private providerOptions: Record<string, RCloneProviderOptionResponse[]> = {};
constructor(
private readonly rcloneApiService: RCloneApiService
) {}
constructor(private readonly rcloneApiService: RCloneApiService) {}
/**
* Loads RClone provider types and options
@@ -32,12 +49,12 @@ export class RCloneFormService {
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;
this.providerNames = 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`);
this.logger.debug(`Loaded ${this.providerNames.length} provider types`);
}
} catch (error) {
this.logger.error(`Error loading provider information: ${error}`);
@@ -45,74 +62,244 @@ export class RCloneFormService {
}
}
/**
* Determines the appropriate input type for a parameter based on its configuration
* Following the logic from rclone-webui-react
*/
private getInputType(attr: any): string {
if (attr.IsPassword) {
return 'password';
} else if (!this.isEmpty(attr.Examples)) {
return 'string';
} else if (attr.Type === 'bool') {
return 'select';
} else if (attr.Type === 'int') {
return 'number';
} else if (attr.Type === 'string') {
return 'text';
} else if (attr.Type === 'SizeSuffix') {
return 'string'; // Special validation will be applied
} else if (attr.Type === 'Duration') {
return 'string'; // Special validation will be applied
} else {
return 'text';
}
}
/**
* Helper method to check if a value is empty (null, undefined, or empty array/object)
* Following the logic from rclone-webui-react
*/
private isEmpty(value: any): boolean {
if (value === null || value === undefined) {
return true;
}
if (Array.isArray(value) && value.length === 0) {
return true;
}
if (typeof value === 'object' && Object.keys(value).length === 0) {
return true;
}
return false;
}
/**
* Validates size suffix format (e.g., "10G", "100M")
* Following the logic from rclone-webui-react
*/
private validateSizeSuffix(value: string): boolean {
if (value === 'off' || value === '') return true;
const regex = /^(\d+)([KMGT])?$/i;
return regex.test(value);
}
/**
* Validates duration format (e.g., "10ms", "1h30m")
* Following the logic from rclone-webui-react
*/
private validateDuration(value: string): boolean {
if (value === '' || value === 'off') return true;
const regex = /^((\d+)h)?((\d+)m)?((\d+)s)?((\d+)ms)?$/i;
return regex.test(value);
}
/**
* Validates integer format
* Following the logic from rclone-webui-react
*/
private validateInt(value: string): boolean {
if (value === '') return true;
const regex = /^\d+$/;
return regex.test(value);
}
/**
* Process provider options to create form fields
* Following the logic from rclone-webui-react
*/
private processProviderOptions(
providerOptions: RCloneProviderOptionResponse[],
loadAdvanced: boolean = false
): {
fields: FieldMetadata[];
required: Record<string, boolean>;
optionTypes: Record<string, string>;
} {
if (!providerOptions) {
return { fields: [], required: {}, optionTypes: {} };
}
const fields: FieldMetadata[] = [];
const required: Record<string, boolean> = {};
const optionTypes: Record<string, string> = {};
for (const attr of providerOptions) {
// Skip hidden options and respect advanced flag
if (
attr.Hide === 0 &&
((loadAdvanced && attr.Advanced) || (!loadAdvanced && !attr.Advanced))
) {
const inputType = this.getInputType(attr);
// Build field metadata
const field: FieldMetadata = {
name: attr.Name,
label: attr.Help || attr.Name,
type: inputType,
required: attr.Required || false,
default: attr.DefaultStr || attr.Default,
examples: attr.Examples || [],
options: attr.Type === 'bool' ? ['Yes', 'No'] : [],
validationType: attr.Type || '',
};
fields.push(field);
// Track required fields
if (attr.Required) {
required[attr.Name] = true;
}
// Track option types for validation
optionTypes[attr.Name] = attr.Type ?? '';
}
}
return { fields, required, optionTypes };
}
/**
* Builds the complete settings schema
* Modified to support both standard and advanced options
*/
async buildSettingsSchema(
providerTypes: string[] = [],
selectedProvider: string = '',
providerOptions: Record<string, any> = {}
loadAdvanced: boolean = false
): Promise<SettingSlice> {
// Ensure provider info is loaded
if (Object.keys(this.providerOptions).length === 0) {
await this.loadProviderInfo();
}
// Get the stepper UI and the form based on the selected provider
const baseSlice = getRcloneConfigSlice();
this.logger.debug('Getting rclone form schema for provider: ' + selectedProvider);
// Check if the form schema generator supports the loadAdvanced parameter
// If not, we'll handle the advanced filtering in our own logic
const formSlice = getRcloneConfigFormSchema(
providerTypes,
this.providerNames,
selectedProvider,
providerOptions
this.providerOptions
);
return mergeSettingSlices([baseSlice, formSlice]);
}
/**
* Returns the data schema for the form
* Returns both data schema and UI schema for the form
* Now supports advanced options toggle
*/
async dataSchema(
providerTypes: string[] = [],
async getFormSchemas(
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) {
loadAdvanced: boolean = false
): Promise<{
dataSchema: Record<string, any>;
uiSchema: Layout;
validationInfo: {
required: Record<string, boolean>;
optionTypes: Record<string, string>;
};
}> {
// Ensure provider info is loaded
if (Object.keys(this.providerOptions).length === 0) {
await this.loadProviderInfo();
providerTypes = this._providerTypes;
providerOptions = this._providerOptions;
}
const { properties } = await this.buildSettingsSchema(
providerTypes,
selectedProvider,
providerOptions
);
const { properties, elements } = await this.buildSettingsSchema(selectedProvider, loadAdvanced);
// Process provider options to get validation information
const providerOpts = selectedProvider ? this.providerOptions[selectedProvider] : [];
const { required, optionTypes } = this.processProviderOptions(providerOpts, loadAdvanced);
return {
type: 'object',
properties,
dataSchema: {
type: 'object',
properties,
},
uiSchema: {
type: 'VerticalLayout',
elements,
},
validationInfo: {
required,
optionTypes,
},
};
}
/**
* Returns the UI schema for the form
* Validates form values based on their types
* Following the logic from rclone-webui-react
*/
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;
validateFormValues(
values: Record<string, any>,
optionTypes: Record<string, string>
): Record<string, { valid: boolean; error: string }> {
const result: Record<string, { valid: boolean; error: string }> = {};
for (const [key, value] of Object.entries(values)) {
const inputType = optionTypes[key];
let isValid = true;
let error = '';
// Skip if no value or no type
if (!value || !inputType) {
result[key] = { valid: true, error: '' };
continue;
}
// Validate based on type
if (inputType === 'SizeSuffix') {
isValid = this.validateSizeSuffix(value as string);
if (!isValid) {
error = 'Valid format: off | {number}{unit} (e.g., 10G, 100M)';
}
} else if (inputType === 'Duration') {
isValid = this.validateDuration(value as string);
if (!isValid) {
error = 'Valid format: {number}{unit} (e.g., 10ms, 1h30m)';
}
} else if (inputType === 'int') {
isValid = this.validateInt(value as string);
if (!isValid) {
error = 'Must be a valid integer';
}
}
result[key] = { valid: isValid, error };
}
const { elements } = await this.buildSettingsSchema(
providerTypes,
selectedProvider,
providerOptions
);
return {
type: 'VerticalLayout',
elements,
};
return result;
}
}
}

View File

@@ -38,7 +38,7 @@ export interface RCloneProviderOptionResponse {
Default?: unknown;
Value?: unknown;
ShortOpt?: string;
Hide?: boolean;
Hide?: number;
Required?: boolean;
IsPassword?: boolean;
NoPrefix?: boolean;
@@ -49,99 +49,6 @@ export interface RCloneProviderOptionResponse {
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)
@@ -152,6 +59,12 @@ export class RCloneBackupConfigForm {
@Field(() => GraphQLJSON)
uiSchema!: Layout;
@Field(() => String, { nullable: true })
providerType?: string;
@Field(() => GraphQLJSON, { nullable: true })
parameters?: Record<string, unknown>;
}
@ObjectType()
@@ -175,7 +88,7 @@ export class RCloneRemote {
type!: string;
@Field(() => GraphQLJSON)
config!: Record<string, unknown>;
parameters!: Record<string, unknown>;
}
@InputType()

View File

@@ -4,7 +4,6 @@ import { RCloneService } from '@app/unraid-api/graph/resolvers/rclone/rclone.ser
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: [],
@@ -13,7 +12,6 @@ import { RCloneConfigResolver } from '@app/unraid-api/graph/resolvers/rclone/rcl
RCloneApiService,
RCloneFormService,
RCloneBackupSettingsResolver,
RCloneConfigResolver
],
exports: [RCloneService, RCloneApiService]
})

View File

@@ -1,22 +1,22 @@
import { Logger } from '@nestjs/common';
import { Args, Parent, Query, ResolveField, Resolver } from '@nestjs/graphql';
import { Args, Mutation, Parent, Query, ResolveField, Resolver } from '@nestjs/graphql';
import { GraphQLJSON } from 'graphql-scalars';
import {
AuthActionVerb,
AuthPossession,
UsePermissions,
} from '@app/unraid-api/graph/directives/use-permissions.directive.js';
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 { CreateRCloneRemoteInput, RCloneBackupConfigForm, RCloneBackupSettings, RCloneRemote } from '@app/unraid-api/graph/resolvers/rclone/rclone.model.js';
import { RCloneService } from './rclone.service.js';
import { RCloneFormService } from './rclone-form.service.js';
import { RCloneService } from './rclone.service.js';
@Resolver(() => RCloneBackupSettings)
export class RCloneBackupSettingsResolver {
@@ -40,28 +40,40 @@ export class RCloneBackupSettingsResolver {
@ResolveField(() => RCloneBackupConfigForm)
async configForm(
@Parent() _parent: RCloneBackupSettings,
@Args('providerType', { nullable: true }) providerType?: string,
@Args('parameters', { type: () => GraphQLJSON, nullable: true }) parameters?: Record<string, unknown>
@Parent() _parent: RCloneBackupSettings,
@Args('providerType', { nullable: true }) providerType?: string,
@Args('parameters', { type: () => GraphQLJSON, nullable: true })
parameters?: Record<string, unknown>
): Promise<RCloneBackupConfigForm> {
// Return basic form info without generating schema data - this will be handled by RCloneConfigResolver
// Return form info with the provided arguments
const form = await this.rcloneFormService.getFormSchemas(providerType);
return {
id: 'rcloneBackupConfigForm',
dataSchema: form.dataSchema,
uiSchema: form.uiSchema,
providerType,
parameters,
} as RCloneBackupConfigForm;
}
@ResolveField(() => [RCloneDrive])
async drives(@Parent() _parent: RCloneBackupSettings): Promise<RCloneDrive[]> {
@Mutation(() => RCloneRemote)
@UsePermissions({
action: AuthActionVerb.CREATE,
resource: Resource.FLASH,
possession: AuthPossession.ANY,
})
async createRCloneRemote(@Args('input') input: CreateRCloneRemoteInput): Promise<RCloneRemote> {
try {
const providers = await this.rcloneApiService.getProviders();
return providers.map(provider => ({
name: provider.name,
options: provider.options as unknown as Record<string, unknown>,
}));
await this.rcloneApiService.createRemote(input.name, input.type, input.parameters);
return {
name: input.name,
type: input.type,
parameters: input.parameters,
};
} catch (error) {
this.logger.error(`Error getting providers: ${error}`);
return [];
this.logger.error(`Error creating remote: ${error}`);
throw new Error(`Failed to create remote: ${error}`);
}
}
@@ -74,4 +86,4 @@ export class RCloneBackupSettingsResolver {
return [];
}
}
}
}

View File

@@ -0,0 +1,36 @@
<script setup lang="ts">
import { Input } from '@/components/form/input';
import ControlLayout from '@/forms/ControlLayout.vue';
import { cn } from '@/lib/utils';
import type { ControlElement } from '@jsonforms/core';
import type { RendererProps } from '@jsonforms/vue';
import { useJsonFormsControl } from '@jsonforms/vue';
import { computed } from 'vue';
const props = defineProps<RendererProps<ControlElement>>();
const { control, handleChange } = useJsonFormsControl(props);
// Bind the input field's value to JSONForms data
const value = computed({
get: () => control.value.data ?? control.value.schema.default ?? '',
set: (newValue: string) => handleChange(control.value.path, newValue || undefined),
});
const classOverride = computed(() => {
return cn(control.value.uischema?.options?.class, {
'max-w-[25ch]': control.value.uischema?.options?.format === 'short',
});
});
</script>
<template>
<ControlLayout v-if="control.visible" :label="control.label" :errors="control.errors">
<Input
v-model="value"
:class="classOverride"
:disabled="!control.enabled"
:required="control.required"
:placeholder="control.schema.description"
/>
</ControlLayout>
</template>

View File

@@ -3,6 +3,7 @@ import PreconditionsLabel from '@/forms/PreconditionsLabel.vue';
import selectRenderer from '@/forms/Select.vue';
import StringArrayField from '@/forms/StringArrayField.vue';
import switchRenderer from '@/forms/Switch.vue';
import inputFieldRenderer from '@/forms/InputField.vue';
import {
and,
isBooleanControl,
@@ -10,6 +11,7 @@ import {
isEnumControl,
isIntegerControl,
isNumberControl,
isStringControl,
optionIs,
or,
rankWith,
@@ -39,6 +41,11 @@ export const numberFieldEntry: JsonFormsRendererRegistryEntry = {
tester: rankWith(4, or(isNumberControl, isIntegerControl)),
};
export const inputFieldEntry: JsonFormsRendererRegistryEntry = {
renderer: inputFieldRenderer,
tester: rankWith(3, isStringControl),
};
export const stringArrayEntry: JsonFormsRendererRegistryEntry = {
renderer: StringArrayField,
tester: rankWith(4, and(isControl, schemaMatches(isStringArray))),

View File

@@ -1,6 +1,7 @@
import {
formSelectEntry,
formSwitchEntry,
inputFieldEntry,
numberFieldEntry,
preconditionsLabelEntry,
stringArrayEntry,
@@ -18,6 +19,7 @@ export const jsonFormsRenderers = [
...vanillaRenderers,
formSwitchEntry,
formSelectEntry,
inputFieldEntry,
numberFieldEntry,
preconditionsLabelEntry,
stringArrayEntry,

View File

@@ -2,7 +2,7 @@
import { computed, ref, watch } from 'vue';
import { useMutation, useQuery } from '@vue/apollo-composable';
import { BrandButton, jsonFormsRenderers } from '@unraid/ui';
import { BrandButton, Input, jsonFormsRenderers } from '@unraid/ui';
import { JsonForms } from '@jsonforms/vue';
import type { CreateRCloneRemoteInput } from '~/composables/gql/graphql';
@@ -31,15 +31,18 @@ const {
result: formResult,
loading: formLoading,
refetch: updateFormSchema,
} = useQuery(GET_RCLONE_CONFIG_FORM, () => ({
} = useQuery(GET_RCLONE_CONFIG_FORM, {
providerType: formState.value.type,
parameters: formState.value.parameters,
}));
});
// Watch for provider type changes to update schema
watch(providerType, async (newType) => {
if (newType) {
await updateFormSchema();
await updateFormSchema({
providerType: newType,
parameters: formState.value.parameters,
});
}
});
@@ -48,7 +51,10 @@ watch(
() => formState.value.configStep,
async (newStep) => {
if (newStep > 0) {
await updateFormSchema();
await updateFormSchema({
providerType: formState.value.type,
parameters: formState.value.parameters,
});
}
}
);

View File

@@ -8,10 +8,6 @@ export const GET_RCLONE_CONFIG_FORM = graphql(/* GraphQL */ `
dataSchema
uiSchema
}
drives {
name
options
}
}
}
`);

View File

@@ -36,7 +36,7 @@ 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($providerType: String, $parameters: JSON) {\n rcloneBackup {\n configForm(providerType: $providerType, parameters: $parameters) { \n dataSchema\n uiSchema\n }\n drives {\n name\n options\n }\n }\n }\n": typeof types.GetRCloneConfigFormDocument,
"\n query GetRCloneConfigForm($providerType: String, $parameters: JSON) {\n rcloneBackup {\n configForm(providerType: $providerType, parameters: $parameters) { \n dataSchema\n uiSchema\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,
@@ -72,7 +72,7 @@ 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($providerType: String, $parameters: JSON) {\n rcloneBackup {\n configForm(providerType: $providerType, parameters: $parameters) { \n dataSchema\n uiSchema\n }\n drives {\n name\n options\n }\n }\n }\n": types.GetRCloneConfigFormDocument,
"\n query GetRCloneConfigForm($providerType: String, $parameters: JSON) {\n rcloneBackup {\n configForm(providerType: $providerType, parameters: $parameters) { \n dataSchema\n uiSchema\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,
@@ -191,7 +191,7 @@ export function graphql(source: "\n subscription NotificationOverviewSub {\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($providerType: String, $parameters: JSON) {\n rcloneBackup {\n configForm(providerType: $providerType, parameters: $parameters) { \n dataSchema\n uiSchema\n }\n drives {\n name\n options\n }\n }\n }\n"): (typeof documents)["\n query GetRCloneConfigForm($providerType: String, $parameters: JSON) {\n rcloneBackup {\n configForm(providerType: $providerType, parameters: $parameters) { \n dataSchema\n uiSchema\n }\n drives {\n name\n options\n }\n }\n }\n"];
export function graphql(source: "\n query GetRCloneConfigForm($providerType: String, $parameters: JSON) {\n rcloneBackup {\n configForm(providerType: $providerType, parameters: $parameters) { \n dataSchema\n uiSchema\n }\n }\n }\n"): (typeof documents)["\n query GetRCloneConfigForm($providerType: String, $parameters: JSON) {\n rcloneBackup {\n configForm(providerType: $providerType, parameters: $parameters) { \n dataSchema\n uiSchema\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/

View File

@@ -2082,7 +2082,7 @@ export type GetRCloneConfigFormQueryVariables = Exact<{
}>;
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 GetRCloneConfigFormQuery = { __typename?: 'Query', rcloneBackup: { __typename?: 'RCloneBackupSettings', configForm: { __typename?: 'RCloneBackupConfigForm', dataSchema: any, uiSchema: any } } };
export type ListRCloneRemotesQueryVariables = Exact<{ [key: string]: never; }>;
@@ -2170,7 +2170,7 @@ 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"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"providerType"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"parameters"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"JSON"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"rcloneBackup"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configForm"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"providerType"},"value":{"kind":"Variable","name":{"kind":"Name","value":"providerType"}}},{"kind":"Argument","name":{"kind":"Name","value":"parameters"},"value":{"kind":"Variable","name":{"kind":"Name","value":"parameters"}}}],"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 GetRCloneConfigFormDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetRCloneConfigForm"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"providerType"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"parameters"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"JSON"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"rcloneBackup"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configForm"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"providerType"},"value":{"kind":"Variable","name":{"kind":"Name","value":"providerType"}}},{"kind":"Argument","name":{"kind":"Name","value":"parameters"},"value":{"kind":"Variable","name":{"kind":"Name","value":"parameters"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"dataSchema"}},{"kind":"Field","name":{"kind":"Name","value":"uiSchema"}}]}}]}}]}}]} 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>;