From d3adbafbffe7dd39fb08d1c6c30bd18552c69c7f Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Mon, 21 Apr 2025 15:44:16 -0400 Subject: [PATCH] chore: lint --- api/generated-schema.graphql | 10 +- .../jsonforms/rclone-jsonforms-config.ts | 477 ++++++++++-------- .../resolvers/rclone/rclone-api.service.ts | 77 ++- .../resolvers/rclone/rclone-form.service.ts | 13 +- .../graph/resolvers/rclone/rclone.model.ts | 28 +- .../graph/resolvers/rclone/rclone.resolver.ts | 19 +- unraid-ui/eslint.config.ts | 26 +- unraid-ui/src/components.ts | 2 +- .../components/common/dropdown-menu/index.ts | 2 +- .../components/common/sheet/SheetFooter.vue | 2 +- .../src/components/modals/ModalTarget.vue | 7 +- unraid-ui/src/components/modals/index.ts | 2 +- unraid-ui/src/forms/ComboBoxField.vue | 98 ++-- unraid-ui/src/forms/ControlWrapper.vue | 7 +- unraid-ui/src/forms/FormErrors.vue | 2 +- unraid-ui/src/forms/HorizontalLayout.vue | 8 +- unraid-ui/src/forms/InputField.vue | 16 +- unraid-ui/src/forms/LabelRenderer.vue | 9 +- unraid-ui/src/forms/NumberField.vue | 28 +- unraid-ui/src/forms/PreconditionsLabel.vue | 18 +- unraid-ui/src/forms/SteppedLayout.vue | 73 ++- unraid-ui/src/forms/StringArrayField.vue | 52 +- unraid-ui/src/forms/Switch.vue | 3 - unraid-ui/src/forms/UnraidSettingsLayout.vue | 5 +- unraid-ui/src/forms/VerticalLayout.vue | 17 +- .../composables/useJsonFormsVisibility.ts | 6 +- unraid-ui/src/forms/renderers.ts | 13 +- unraid-ui/src/index.ts | 1 - unraid-ui/src/lib/utils.ts | 2 +- unraid-ui/src/register.ts | 18 +- unraid-ui/src/vite-env.d.ts | 2 +- web/components/RClone/RCloneConfig.vue | 60 ++- .../RClone/graphql/settings.query.ts | 7 +- web/composables/gql/gql.ts | 12 +- web/composables/gql/graphql.ts | 58 +-- 35 files changed, 680 insertions(+), 500 deletions(-) diff --git a/api/generated-schema.graphql b/api/generated-schema.graphql index ff52732ae..dba7fec7d 100644 --- a/api/generated-schema.graphql +++ b/api/generated-schema.graphql @@ -1357,16 +1357,20 @@ type RCloneBackupConfigForm { id: ID! dataSchema: JSON! uiSchema: JSON! - providerType: String - parameters: JSON } type RCloneBackupSettings { - configForm(providerType: String, parameters: JSON): RCloneBackupConfigForm! + configForm(formOptions: RCloneConfigFormInput): RCloneBackupConfigForm! drives: [RCloneDrive!]! remotes: [String!]! } +input RCloneConfigFormInput { + providerType: String + showAdvanced: Boolean = false + parameters: JSON +} + type RCloneRemote { name: String! type: String! diff --git a/api/src/unraid-api/graph/resolvers/rclone/jsonforms/rclone-jsonforms-config.ts b/api/src/unraid-api/graph/resolvers/rclone/jsonforms/rclone-jsonforms-config.ts index d0e76f384..3e179466a 100644 --- a/api/src/unraid-api/graph/resolvers/rclone/jsonforms/rclone-jsonforms-config.ts +++ b/api/src/unraid-api/graph/resolvers/rclone/jsonforms/rclone-jsonforms-config.ts @@ -1,4 +1,4 @@ -import type { Layout, SchemaBasedCondition, ControlElement, LabelElement } from '@jsonforms/core'; +import type { ControlElement, LabelElement, Layout, Rule, SchemaBasedCondition } from '@jsonforms/core'; import { JsonSchema7, RuleEffect } from '@jsonforms/core'; import { filter } from 'rxjs'; @@ -6,6 +6,53 @@ import type { DataSlice, SettingSlice, UIElement } from '@app/unraid-api/types/j import { RCloneProviderOptionResponse } from '@app/unraid-api/graph/resolvers/rclone/rclone.model.js'; import { mergeSettingSlices } from '@app/unraid-api/types/json-forms.js'; +// --- START: Added Helper Function --- +/** + * Creates a HorizontalLayout containing a Label and a Control element. + */ +function createLabeledControl({ + scope, + label, + description, + controlOptions, + labelOptions, + layoutOptions, + rule, +}: { + scope: string; + label: string; + description?: string; + controlOptions: ControlElement['options']; + labelOptions?: LabelElement['options']; + layoutOptions?: Layout['options']; + rule?: Rule; +}): Layout { + const layout: Layout & { scope?: string } = { + type: 'UnraidSettingsLayout', + scope: scope, // Apply scope to the layout for potential rules/visibility based on the field itself + options: layoutOptions, + elements: [ + { + type: 'Label', + text: label, + scope: scope, // Scope might be needed for specific label behaviors + options: { ...labelOptions, description }, + } as LabelElement, + { + type: 'Control', + scope: scope, + options: controlOptions, + } as ControlElement, + ], + }; + // Conditionally add the rule to the layout if provided + if (rule) { + layout.rule = rule; + } + return layout; +} +// --- END: Added Helper Function --- + /** * Translates RClone config option to JsonSchema properties */ @@ -43,8 +90,9 @@ function translateRCloneOptionToJsonSchema({ examples: option.Examples?.map((example) => example.Value), isPassword: option.IsPassword, }); - if (format && format !== schema.type) { + if (format && format !== schema.type && format !== 'combobox') { // Don't add format if it's just the type (e.g., 'number') + // Don't add non-standard UI hints like 'combobox' to the schema format schema.format = format; } @@ -66,13 +114,13 @@ function translateRCloneOptionToJsonSchema({ 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.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))+)$'; // µs is µs + schema.pattern = '^(off|(d+(.d+)?(ns|us|\u00b5s|ms|s|m|h))+)$'; schema.errorMessage = 'Invalid duration format. Examples: "10s", "1.5m", "100ms", "1h15m", "off".'; break; @@ -87,38 +135,28 @@ function translateRCloneOptionToJsonSchema({ */ function getBasicConfigSlice({ providerTypes }: { providerTypes: string[] }): SettingSlice { // Create UI elements for basic configuration (Step 1) - const basicConfigElements: (ControlElement | LabelElement | Layout)[] = [ - { - type: 'HorizontalLayout', + const basicConfigElements: UIElement[] = [ + // --- START: Refactored 'name' field using helper --- + createLabeledControl({ scope: '#/properties/name', - elements: [ - { - type: 'Label', - scope: '#/properties/name', - text: 'Remote Name', - options: { - // Optional styling - }, - } as LabelElement, - { - type: 'Control', - scope: '#/properties/name', - options: { - placeholder: 'Enter a name', - format: 'string', - description: 'Name to identify this remote configuration (e.g., my_google_drive). Use only letters, numbers, hyphens, and underscores.', - }, - } as ControlElement, - ], - } as Layout, - { - type: 'Control', + label: 'Remote Name', + description: + 'Name to identify this remote configuration (e.g., my_google_drive). Use only letters, numbers, hyphens, and underscores.', + controlOptions: { + placeholder: 'Enter a name', + format: 'string', + }, + // Add layoutOptions if needed, e.g., layoutOptions: { style: 'margin-bottom: 1em;' } + }), + // --- END: Refactored 'name' field using helper --- + + // --- START: Refactored 'type' field using helper --- + createLabeledControl({ scope: '#/properties/type', label: 'Storage Provider Type', - options: { - description: 'Select the cloud storage provider to use for this remote.', - }, - }, + description: 'Select the cloud storage provider to use for this remote.', + controlOptions: {}, + }), { type: 'Label', text: 'Documentation Link', @@ -126,35 +164,20 @@ function getBasicConfigSlice({ providerTypes }: { providerTypes: string[] }): Se description: 'For more information, refer to the [RClone Config Documentation](https://rclone.org/commands/rclone_config/).', }, - }, - // --- START: Added HorizontalLayout with visibility rule for testing --- - { - type: 'HorizontalLayout', - rule: { - effect: RuleEffect.HIDE, - condition: { - scope: '#/properties/name', - schema: { const: 'hide_me' }, // Hide if name is exactly 'hide_me' - }, + } as LabelElement, + createLabeledControl({ + scope: '#/properties/showAdvanced', + label: 'Show Advanced Options', + description: 'Display additional configuration options for experts.', + controlOptions: { + toggle: true, }, - elements: [ - { - type: 'Label', - text: 'Hidden Field Label', - } as LabelElement, - { - type: 'Control', - scope: '#/properties/hiddenField', // Needs corresponding schema property - options: { - placeholder: 'This field is hidden if name is hide_me', - }, - } as ControlElement, - ], - } as Layout, - // --- END: Added HorizontalLayout with visibility rule for testing --- + layoutOptions: { + style: 'margin-top: 1em;', + }, + }), ]; - // Define the data schema for basic configuration const basicConfigProperties: Record = { name: { type: 'string', @@ -170,41 +193,39 @@ function getBasicConfigSlice({ providerTypes }: { providerTypes: string[] }): Se default: providerTypes.length > 0 ? providerTypes[0] : '', enum: providerTypes, }, - // --- START: Added schema property for the hidden field --- - hiddenField: { - type: 'string', - title: 'Hidden Field', - description: 'This field should only be visible when the name is not \'hide_me\'', + showAdvanced: { + type: 'boolean', + title: 'Show Advanced Options', + description: 'Whether to show advanced configuration options.', + default: false, }, - // --- END: Added schema property for the hidden field --- }; - // Wrap the basic elements in a VerticalLayout marked for step 0 const verticalLayoutElement: UIElement = { type: 'VerticalLayout', elements: basicConfigElements, - options: { step: 0 }, // Assign to step 0 + options: { step: 0 }, }; return { properties: basicConfigProperties as unknown as DataSlice, - elements: [verticalLayoutElement], // Return the VerticalLayout as the single element + elements: [verticalLayoutElement], }; } /** - * Step 2/3: Provider-specific configuration based on the selected provider and type (standard/advanced). + * Step 1/2: Provider-specific configuration (standard or advanced). * Returns a SettingSlice containing properties and a VerticalLayout UI element with options.step = stepIndex. */ export function getProviderConfigSlice({ selectedProvider, providerOptions, - type = 'standard', - stepIndex, // Added stepIndex parameter + isAdvancedStep, // Flag to determine if this is for the advanced step + stepIndex, }: { selectedProvider: string; providerOptions: RCloneProviderOptionResponse[]; - type?: 'standard' | 'advanced'; + isAdvancedStep: boolean; // True if fetching advanced options, false for standard stepIndex: number; // Required step index for the rule }): SettingSlice { // Default properties when no provider is selected @@ -217,14 +238,16 @@ export function getProviderConfigSlice({ }; } - // Filter options based on the type (standard/advanced) + // Filter options based on whether we are fetching standard or advanced options const filteredOptions = providerOptions.filter((option) => { - if (type === 'advanced' && option.Advanced === true) { - return true; - } else if (type === 'standard' && option.Advanced !== true) { - return true; + // If fetching advanced, include only options marked as Advanced + if (isAdvancedStep) { + return option.Advanced === true; + } + // If fetching standard, include only options *not* marked as Advanced + else { + return option.Advanced !== true; } - return false; }); // Ensure uniqueness based on Name *within this slice* to prevent overwrites @@ -232,7 +255,9 @@ export function getProviderConfigSlice({ if (!acc.find((item) => item.Name === current.Name)) { acc.push(current); } else { - console.warn(`Duplicate RClone option name skipped in ${type} slice: ${current.Name}`); + console.warn( + `Duplicate RClone option name skipped in ${isAdvancedStep ? 'advanced' : 'standard'} slice: ${current.Name}` + ); } return acc; }, [] as RCloneProviderOptionResponse[]); @@ -246,79 +271,113 @@ export function getProviderConfigSlice({ } // Create dynamic UI control elements based on unique provider options - const controlElements = uniqueOptionsByName.map((option) => { - const format = getJsonFormElementForType({ - rcloneType: option.Type, - examples: option.Examples?.map((example) => example.Value), - isPassword: option.IsPassword, - }); + const controlElements = uniqueOptionsByName + // Filter out elements that are always hidden (Hide=1 without a provider filter) + .filter((option) => { + const providerFilter = option.Provider?.trim(); + return !(option.Hide === 1 && !providerFilter); + }) + .map((option): UIElement => { + const format = getJsonFormElementForType({ + rcloneType: option.Type, + examples: option.Examples?.map((example) => example.Value), + isPassword: option.IsPassword, + }); - const controlOptions: Record = { - placeholder: option.Default?.toString() || '', - help: option.Help || '', - required: option.Required || false, - format, - hide: option.Hide === 1, - }; + const controlOptions: Record = { + placeholder: option.Default?.toString() || '', + // help: option.Help || '', // Help/Description should now be part of the control options for tooltip/aria + required: option.Required || false, + format, // Pass format hint + // hide: option.Hide === 1, // Hiding is handled by the rule effect below if needed, or potentially JSON Forms renderer behavior + }; - // Use examples for placeholder if available - if (option.Examples && option.Examples.length > 0) { - const exampleValues = option.Examples.map((example) => example.Value).join(', '); - controlOptions.placeholder = `e.g., ${exampleValues}`; - } - - // Only add toggle option for boolean fields without examples - if (format === 'checkbox' && (!option.Examples || option.Examples.length === 0)) { - controlOptions.toggle = true; - } - - // Add examples as suggestions for combobox - if (format === 'combobox' && option.Examples && option.Examples.length > 0) { - controlOptions.suggestions = option.Examples.map((example) => ({ - value: example.Value, - label: example.Value, // Set label to just the value - tooltip: example.Help || '', // Add tooltip with help text - })); - } - - // --- Start: Add dynamic visibility rule based on Provider --- // - let providerRule: { effect: RuleEffect; condition: SchemaBasedCondition } | undefined = - undefined; - const providerFilter = option.Provider?.trim(); - - if (providerFilter) { - const isNegated = providerFilter.startsWith('!'); - const providers = (isNegated ? providerFilter.substring(1) : providerFilter) - .split(',') - .map((p) => p.trim()) - .filter((p) => p); - - if (providers.length > 0) { - const conditionSchema = isNegated - ? { not: { enum: providers } } // Show if type is NOT in the list - : { enum: providers }; // Show if type IS in the list - - providerRule = { - effect: RuleEffect.SHOW, - condition: { - scope: '#/properties/type', - schema: conditionSchema, - } as SchemaBasedCondition, - }; + // Use examples for placeholder if available + if (option.Examples && option.Examples.length > 0) { + const exampleValues = option.Examples.map((example) => example.Value).join(', '); + controlOptions.placeholder = `e.g., ${exampleValues}`; } - } - // --- End: Add dynamic visibility rule based on Provider --- // - const uiElement: UIElement = { - type: 'Control', - scope: `#/properties/parameters/properties/${option.Name}`, - label: option.Help || option.Name, - options: controlOptions, - // Add the provider-specific rule if it was generated - ...(providerRule && { rule: providerRule }), - }; - return uiElement; - }); + // Only add toggle option for boolean fields without examples + if (format === 'checkbox' && (!option.Examples || option.Examples.length === 0)) { + controlOptions.toggle = true; + } + + // Add examples as suggestions for combobox + if (format === 'combobox' && option.Examples && option.Examples.length > 0) { + // Check if the underlying type is boolean, handle undefined option.Type + const isBooleanType = getJsonSchemaType(option.Type ?? '') === 'boolean'; + controlOptions.suggestions = option.Examples.map((example) => ({ + // Parse string "true"/"false" to boolean if the type is boolean, handle potential null/undefined + value: isBooleanType + ? String(example.Value ?? '').toLowerCase() === 'true' + : example.Value, + // Ensure label is also a string, even if value is null/undefined + label: String(example.Value ?? ''), + tooltip: example.Help || '', + })); + } + + // --- Start: Add dynamic visibility rule based on Provider --- // + let providerRule: Rule | undefined = undefined; // Define rule type explicitly + const providerFilter = option.Provider?.trim(); + + if (providerFilter) { + const isNegated = providerFilter.startsWith('!'); + const providers = (isNegated ? providerFilter.substring(1) : providerFilter) + .split(',') + .map((p) => p.trim()) + .filter((p) => p); + + if (providers.length > 0) { + const conditionSchema = isNegated + ? { not: { enum: providers } } + : { enum: providers }; + + // Show/Hide logic: If option.Hide === 1, we HIDE, otherwise default SHOW based on provider type + const effect = option.Hide === 1 ? RuleEffect.HIDE : RuleEffect.SHOW; + + providerRule = { + effect: effect, + condition: { + scope: '#/properties/type', + schema: conditionSchema, + } as SchemaBasedCondition, + }; + } + } else if (option.Hide === 1) { + // If no provider filter, but Hide is set, create a rule to always hide + // This needs a condition that is always true, which is tricky. + // A simple approach is a condition that will likely always evaluate based on the schema. + // Alternatively, rely on JSON Forms renderer interpretation of a missing rule but Hide=1 property. + // For robustness, let's add a rule that hides if the field itself exists (which it always will if rendered). + // Note: This specific 'always hide' might need adjustment based on JSON Forms implementation details. + // A more direct approach might be filtering these out *before* mapping if always hidden. + // Let's assume for now `option.Hide=1` without a provider filter means it's *always* hidden. + // We can filter these out instead of creating a complex rule. + // Revisit this if hidden fields without provider filters are needed dynamically. + + // --- Simplified Logic: Filter out permanently hidden fields --- + if (option.Hide === 1 && !providerFilter) { + // Skip creating a UI element for this option entirely + // This case is now handled by the filter above + } + } + // --- End: Add dynamic visibility rule based on Provider --- // + + // --- Use the helper function --- + const labeledControl = createLabeledControl({ + scope: `#/properties/parameters/properties/${option.Name}`, + // Use Name as fallback label if Help is empty, otherwise use Help for label + label: option.Name, // Use Name for the label text + description: option.Help || undefined, + controlOptions: controlOptions, + rule: providerRule, // Apply the rule to the HorizontalLayout wrapper + }); + + // Layout is a valid UIElement, no cast needed if filter handles nulls + return labeledControl; + }); // Create dynamic properties schema based on unique provider options const paramProperties: Record = {}; @@ -347,8 +406,8 @@ export function getProviderConfigSlice({ // Wrap the control elements in a VerticalLayout marked for the specified stepIndex const verticalLayoutElement: UIElement = { type: 'VerticalLayout', - elements: controlElements, - options: { step: stepIndex }, // Assign to the specified stepIndex + elements: controlElements, // Use the refactored elements + options: { step: stepIndex, showDividers: true }, // Assign stepIndex and add showDividers }; return { @@ -358,7 +417,7 @@ export function getProviderConfigSlice({ } /** - * Helper function to convert RClone option types to JSON Schema types + * Helper function to convert RClone type to a basic JSON Schema type string (e.g., 'string', 'number', 'boolean'). */ function getJsonSchemaType(rcloneType: string): string { switch (rcloneType?.toLowerCase()) { @@ -400,19 +459,18 @@ function getJsonFormElementForType({ switch (rcloneType?.toLowerCase()) { case 'int': - return 'number'; // Use NumberField case 'size': - return 'number'; // Use NumberField + // Schema type 'integer'/'number' is sufficient. + // UI framework should infer NumberField from schema type. + return undefined; case 'sizesuffix': return undefined; // Use default InputField (via isStringControl) case 'duration': return undefined; // Use default InputField (via isStringControl) case 'bool': - // Only use checkbox/toggle for boolean fields without examples - if (!examples || examples.length === 0) { - return 'checkbox'; - } - return 'combobox'; // Use combobox for boolean fields with examples + // ALWAYS use checkbox/toggle for boolean fields, regardless of examples. + // RClone examples ("true"/"false") don't map well to UI boolean controls. + return 'toggle'; case 'text': // Consider 'textarea' format later if needed return undefined; // Use default InputField (via isStringControl) @@ -436,74 +494,95 @@ export function buildRcloneConfigSchema({ providerTypes = [], selectedProvider = '', providerOptions = {}, + showAdvanced = false, }: { providerTypes?: string[]; selectedProvider?: string; providerOptions?: Record; + showAdvanced?: boolean; }): { - dataSchema: Record; + dataSchema: { properties: DataSlice; type: 'object' }; uiSchema: Layout; } { - // --- Schema Definition --- - - // Define the step control property - REMOVED as SteppedLayout uses local state - // const stepControlProperty: Record = { - // configStep: { - // type: 'number', - // minimum: 0, - // maximum: 2, // 3 steps: 0, 1, 2 - // default: 0, - // }, - // }; - - // --- Step Content Generation --- - const optionsForProvider = providerOptions[selectedProvider] || []; + const slicesToMerge: SettingSlice[] = []; - // Step 0: Basic Config + // Step 0: Basic Config (Always included) const basicSlice = getBasicConfigSlice({ providerTypes }); + slicesToMerge.push(basicSlice); - // Step 1: Standard Provider Config - const standardConfigSlice = getProviderConfigSlice({ - selectedProvider, - providerOptions: optionsForProvider, - type: 'standard', - stepIndex: 1, // Assign to step 1 - }); + // Step 1: Standard Provider Config (Always included if provider selected) + if (selectedProvider && optionsForProvider.length > 0) { + const standardConfigSlice = getProviderConfigSlice({ + selectedProvider, + providerOptions: optionsForProvider, + isAdvancedStep: false, // Fetch standard options + stepIndex: 1, + }); + // Only add if there are actual standard options + if ( + standardConfigSlice.elements.length > 0 || + Object.keys(standardConfigSlice.properties).length > 0 + ) { + slicesToMerge.push(standardConfigSlice); + } + } - // Step 2: Advanced Provider Config - const advancedConfigSlice = getProviderConfigSlice({ - selectedProvider, - providerOptions: optionsForProvider, - type: 'advanced', - stepIndex: 2, // Assign to step 2 - }); + // Step 2: Advanced Provider Config (Conditionally included) + let advancedConfigSlice: SettingSlice | null = null; + if (showAdvanced && selectedProvider && optionsForProvider.length > 0) { + advancedConfigSlice = getProviderConfigSlice({ + selectedProvider, + providerOptions: optionsForProvider, + isAdvancedStep: true, // Fetch advanced options + stepIndex: 2, + }); + // Only add if there are actual advanced options + if ( + advancedConfigSlice.elements.length > 0 || + Object.keys(advancedConfigSlice.properties).length > 0 + ) { + slicesToMerge.push(advancedConfigSlice); + } + } - // Merge all properties: basic + standard + advanced - const mergedProperties = mergeSettingSlices([basicSlice, standardConfigSlice, advancedConfigSlice]); + // Merge all relevant slices + const mergedSlices = mergeSettingSlices(slicesToMerge); // Construct the final dataSchema - const dataSchema = { + // Add explicit type annotation to satisfy stricter type checking + const dataSchema: { properties: DataSlice; type: 'object' } = { type: 'object', - properties: mergedProperties.properties, + properties: mergedSlices.properties, // Add required fields if necessary, e.g., ['name', 'type'] // required: ['name', 'type'], // Example: Make name and type required globally }; // --- UI Schema Definition --- - // Define the SteppedLayout UI element, now containing step content elements + // Define steps based on whether advanced options are shown + const steps = [{ label: 'Set up Remote Config', description: 'Name and provider selection' }]; + + if (selectedProvider) { + steps.push({ label: 'Set up Drive', description: 'Provider-specific configuration' }); + } + if ( + showAdvanced && + advancedConfigSlice && + (advancedConfigSlice.elements.length > 0 || + Object.keys(advancedConfigSlice.properties).length > 0) + ) { + steps.push({ label: 'Advanced Config', description: 'Optional advanced settings' }); + } + + // Define the SteppedLayout UI element const steppedLayoutElement: UIElement = { type: 'SteppedLayout', options: { - 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' }, - ], + steps: steps, // Use dynamically generated steps }, // Nest the step content elements directly inside the SteppedLayout - elements: mergedProperties.elements, + elements: mergedSlices.elements, }; // Define the overall title label diff --git a/api/src/unraid-api/graph/resolvers/rclone/rclone-api.service.ts b/api/src/unraid-api/graph/resolvers/rclone/rclone-api.service.ts index 043bd2562..a301b2d14 100644 --- a/api/src/unraid-api/graph/resolvers/rclone/rclone-api.service.ts +++ b/api/src/unraid-api/graph/resolvers/rclone/rclone-api.service.ts @@ -6,7 +6,7 @@ import { mkdir, rm, writeFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { execa } from 'execa'; -import got from 'got'; +import got, { HTTPError } from 'got'; import { RCloneProviderOptionResponse, @@ -270,15 +270,20 @@ export class RCloneApiService implements OnModuleInit, OnModuleDestroy { * Create a new remote configuration */ async createRemote(name: string, type: string, parameters: Record = {}): Promise { - // Combine the required parameters for the create request + // Structure the payload as expected by Rclone API const params = { name, type, - ...parameters, + parameters: parameters, // Nest the parameters object under the 'parameters' key }; - this.logger.log(`Creating new remote: ${name} of type: ${type}`); - return this.callRcloneApi('config/create', params); + this.logger.log( + `Creating new remote: ${name} of type: ${type} with params: ${JSON.stringify(params)}` + ); // Added params logging + const result = await this.callRcloneApi('config/create', params); + // console.log('Result was: ', result); // Result is usually empty on success, potentially remove + this.logger.log(`Successfully created remote: ${name}`); // Improved success log + return result; // Rclone 'config/create' usually returns an empty object on success } /** @@ -339,8 +344,8 @@ export class RCloneApiService implements OnModuleInit, OnModuleDestroy { * Generic method to call the RClone RC API */ private async callRcloneApi(endpoint: string, params: Record = {}): Promise { + const url = `${this.rcloneBaseUrl}/${endpoint}`; try { - const url = `${this.rcloneBaseUrl}/${endpoint}`; this.logger.debug(`Calling RClone API: ${url} with params: ${JSON.stringify(params)}`); const response = await got.post(url, { @@ -350,13 +355,67 @@ export class RCloneApiService implements OnModuleInit, OnModuleDestroy { headers: { Authorization: `Basic ${Buffer.from(`${this.rcloneUsername}:${this.rclonePassword}`).toString('base64')}`, }, + // Add timeout? retry logic? Consider these based on need. }); 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; + let detailedErrorMessage = 'An unknown error occurred'; + if (error instanceof HTTPError) { + const statusCode = error.response.statusCode; + let rcloneError = 'Could not extract Rclone error details.'; + const responseBody = error.response.body; // Get the body + + try { + let errorBody: any; + // Check if the body is a string that needs parsing or already an object + if (typeof responseBody === 'string') { + errorBody = JSON.parse(responseBody); + } else if (typeof responseBody === 'object' && responseBody !== null) { + errorBody = responseBody; // It's already an object + } + + if (errorBody && errorBody.error) { + rcloneError = `Rclone Error: ${errorBody.error}`; + // Add input details if available, check for different structures + if (errorBody.input) { + rcloneError += ` | Input: ${JSON.stringify(errorBody.input)}`; + } else if (params) { + // Fallback to original params if errorBody.input is missing + rcloneError += ` | Original Params: ${JSON.stringify(params)}`; + } + } else if (responseBody) { + // Body exists but doesn't match expected error structure + rcloneError = `Non-standard error response body: ${typeof responseBody === 'string' ? responseBody : JSON.stringify(responseBody)}`; + } else { + rcloneError = 'Empty error response body received.'; + } + } catch (parseOrAccessError) { + // Handle errors during parsing or accessing properties + rcloneError = `Failed to process error response body. Raw body: ${typeof responseBody === 'string' ? responseBody : JSON.stringify(responseBody)}`; + } + // Construct the detailed message for the new error + detailedErrorMessage = `Rclone API Error (${endpoint}, HTTP ${statusCode}): ${rcloneError}`; + + // Log the detailed error including the original stack if available + this.logger.error( + `Original ${detailedErrorMessage} | Params: ${JSON.stringify(params)}`, + error.stack // Log the original HTTPError stack + ); + + // Throw a NEW error with the detailed Rclone message + throw new Error(detailedErrorMessage); + } else if (error instanceof Error) { + // For non-HTTP errors, log and re-throw as before + detailedErrorMessage = `Error calling RClone API (${endpoint}) with params ${JSON.stringify(params)}: ${error.message}`; + this.logger.error(detailedErrorMessage, error.stack); + throw error; // Re-throw original non-HTTP error + } else { + // Handle unknown error types + detailedErrorMessage = `Unknown error calling RClone API (${endpoint}) with params ${JSON.stringify(params)}: ${String(error)}`; + this.logger.error(detailedErrorMessage); + throw new Error(detailedErrorMessage); // Throw a new error for unknown types + } } } diff --git a/api/src/unraid-api/graph/resolvers/rclone/rclone-form.service.ts b/api/src/unraid-api/graph/resolvers/rclone/rclone-form.service.ts index 52eebc33c..d0ae11009 100644 --- a/api/src/unraid-api/graph/resolvers/rclone/rclone-form.service.ts +++ b/api/src/unraid-api/graph/resolvers/rclone/rclone-form.service.ts @@ -4,7 +4,11 @@ import { type Layout } from '@jsonforms/core'; import { buildRcloneConfigSchema } from '@app/unraid-api/graph/resolvers/rclone/jsonforms/rclone-jsonforms-config.js'; import { RCloneApiService } from '@app/unraid-api/graph/resolvers/rclone/rclone-api.service.js'; -import { RCloneProviderOptionResponse } from '@app/unraid-api/graph/resolvers/rclone/rclone.model.js'; +import { + RCloneConfigFormInput, + RCloneProviderOptionResponse, +} from '@app/unraid-api/graph/resolvers/rclone/rclone.model.js'; +import { DataSlice } from '@app/unraid-api/types/json-forms.js'; /** * Service responsible for generating form UI schemas and form logic @@ -41,10 +45,12 @@ export class RCloneFormService { /** * Returns both data schema and UI schema for the form */ - async getFormSchemas(selectedProvider: string = ''): Promise<{ - dataSchema: Record; + async getFormSchemas(options: RCloneConfigFormInput): Promise<{ + dataSchema: { properties: DataSlice; type: 'object' }; uiSchema: Layout; }> { + const { providerType: selectedProvider = '', showAdvanced = false } = options; + // Ensure provider info is loaded if (Object.keys(this.providerOptions).length === 0) { await this.loadProviderInfo(); @@ -54,6 +60,7 @@ export class RCloneFormService { providerTypes: this.providerNames, selectedProvider, providerOptions: this.providerOptions, + showAdvanced, }); } } diff --git a/api/src/unraid-api/graph/resolvers/rclone/rclone.model.ts b/api/src/unraid-api/graph/resolvers/rclone/rclone.model.ts index 89decfaed..b14f2d95e 100644 --- a/api/src/unraid-api/graph/resolvers/rclone/rclone.model.ts +++ b/api/src/unraid-api/graph/resolvers/rclone/rclone.model.ts @@ -1,6 +1,7 @@ import { Field, ID, InputType, ObjectType } from '@nestjs/graphql'; import { type Layout } from '@jsonforms/core'; +import { IsBoolean, IsObject, IsOptional, IsString } from 'class-validator'; import { GraphQLJSON } from 'graphql-scalars'; import { DataSlice } from '@app/unraid-api/types/json-forms.js'; @@ -49,6 +50,24 @@ export interface RCloneProviderOptionResponse { Examples?: Array<{ Value: string; Help: string; Provider: string }>; } +@InputType() +export class RCloneConfigFormInput { + @Field(() => String, { nullable: true }) + @IsOptional() + @IsString() + providerType?: string; + + @Field(() => Boolean, { defaultValue: false, nullable: true }) + @IsOptional() + @IsBoolean() + showAdvanced?: boolean; + + @Field(() => GraphQLJSON, { nullable: true }) + @IsOptional() + @IsObject() + parameters?: Record; +} + @ObjectType() export class RCloneBackupConfigForm { @Field(() => ID) @@ -59,12 +78,6 @@ export class RCloneBackupConfigForm { @Field(() => GraphQLJSON) uiSchema!: Layout; - - @Field(() => String, { nullable: true }) - providerType?: string; - - @Field(() => GraphQLJSON, { nullable: true }) - parameters?: Record; } @ObjectType() @@ -94,11 +107,14 @@ export class RCloneRemote { @InputType() export class CreateRCloneRemoteInput { @Field(() => String) + @IsString() name!: string; @Field(() => String) + @IsString() type!: string; @Field(() => GraphQLJSON) + @IsObject() parameters!: Record; } diff --git a/api/src/unraid-api/graph/resolvers/rclone/rclone.resolver.ts b/api/src/unraid-api/graph/resolvers/rclone/rclone.resolver.ts index 3f5333a8c..bb4fe52a1 100644 --- a/api/src/unraid-api/graph/resolvers/rclone/rclone.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/rclone/rclone.resolver.ts @@ -1,8 +1,6 @@ import { Logger } from '@nestjs/common'; import { Args, Mutation, Parent, Query, ResolveField, Resolver } from '@nestjs/graphql'; -import { GraphQLJSON } from 'graphql-scalars'; - import { AuthActionVerb, AuthPossession, @@ -15,9 +13,11 @@ import { CreateRCloneRemoteInput, RCloneBackupConfigForm, RCloneBackupSettings, + RCloneConfigFormInput, RCloneRemote, } from '@app/unraid-api/graph/resolvers/rclone/rclone.model.js'; import { RCloneService } from '@app/unraid-api/graph/resolvers/rclone/rclone.service.js'; +import { DataSlice } from '@app/unraid-api/types/json-forms.js'; @Resolver(() => RCloneBackupSettings) export class RCloneBackupSettingsResolver { @@ -42,20 +42,15 @@ export class RCloneBackupSettingsResolver { @ResolveField(() => RCloneBackupConfigForm) async configForm( @Parent() _parent: RCloneBackupSettings, - @Args('providerType', { nullable: true }) providerType?: string, - @Args('parameters', { type: () => GraphQLJSON, nullable: true }) - parameters?: Record + @Args('formOptions', { type: () => RCloneConfigFormInput, nullable: true }) + formOptions?: RCloneConfigFormInput ): Promise { - // Return form info with the provided arguments - - const form = await this.rcloneFormService.getFormSchemas(providerType); + const form = await this.rcloneFormService.getFormSchemas(formOptions ?? {}); return { id: 'rcloneBackupConfigForm', - dataSchema: form.dataSchema, + dataSchema: form.dataSchema as { properties: DataSlice; type: 'object' }, uiSchema: form.uiSchema, - providerType, - parameters, - } as RCloneBackupConfigForm; + }; } @Mutation(() => RCloneRemote) diff --git a/unraid-ui/eslint.config.ts b/unraid-ui/eslint.config.ts index a0bba4374..bbf873f2e 100644 --- a/unraid-ui/eslint.config.ts +++ b/unraid-ui/eslint.config.ts @@ -71,13 +71,19 @@ const vueRules = { const commonLanguageOptions = { ecmaVersion: 'latest', sourceType: 'module', - globals: { - browser: true, - window: true, - document: true, - es2022: true, - HTMLElement: true, - }, +}; + +// Define globals separately +const commonGlobals = { + browser: true, + window: true, + document: true, + console: true, + Event: true, + HTMLElement: true, + HTMLInputElement: true, + CustomEvent: true, + es2022: true, }; export default [ @@ -96,6 +102,9 @@ export default [ jsx: true, }, }, + globals: { + ...commonGlobals + }, }, plugins: { 'no-relative-import-paths': noRelativeImportPaths, @@ -119,6 +128,9 @@ export default [ jsx: true, }, }, + globals: { + ...commonGlobals + }, }, plugins: { 'no-relative-import-paths': noRelativeImportPaths, diff --git a/unraid-ui/src/components.ts b/unraid-ui/src/components.ts index d77e6ad51..d486f79f8 100644 --- a/unraid-ui/src/components.ts +++ b/unraid-ui/src/components.ts @@ -17,4 +17,4 @@ export * from '@/components/common/tabs'; export * from '@/components/common/tooltip'; export * from '@/components/common/toast'; export * from '@/components/common/popover'; -export * from '@/components/modals'; \ No newline at end of file +export * from '@/components/modals'; diff --git a/unraid-ui/src/components/common/dropdown-menu/index.ts b/unraid-ui/src/components/common/dropdown-menu/index.ts index 01591f697..02b99fd51 100644 --- a/unraid-ui/src/components/common/dropdown-menu/index.ts +++ b/unraid-ui/src/components/common/dropdown-menu/index.ts @@ -1,4 +1,4 @@ -import DropdownMenu from './DropdownMenu.vue'; +import DropdownMenu from '@/components/common/dropdown-menu/DropdownMenu.vue'; export { DropdownMenu }; export { default as DropdownMenuCheckboxItem } from './DropdownMenuCheckboxItem.vue'; diff --git a/unraid-ui/src/components/common/sheet/SheetFooter.vue b/unraid-ui/src/components/common/sheet/SheetFooter.vue index 4ee0475c1..5f3b7dff9 100644 --- a/unraid-ui/src/components/common/sheet/SheetFooter.vue +++ b/unraid-ui/src/components/common/sheet/SheetFooter.vue @@ -6,7 +6,7 @@ const props = defineProps<{ class?: HTMLAttributes['class'] }>(); diff --git a/unraid-ui/src/components/modals/ModalTarget.vue b/unraid-ui/src/components/modals/ModalTarget.vue index 1875e0fdc..96b6d1c68 100644 --- a/unraid-ui/src/components/modals/ModalTarget.vue +++ b/unraid-ui/src/components/modals/ModalTarget.vue @@ -1,5 +1,4 @@ - + \ No newline at end of file +
+ diff --git a/unraid-ui/src/components/modals/index.ts b/unraid-ui/src/components/modals/index.ts index 9e6d89df9..8cb8f6388 100644 --- a/unraid-ui/src/components/modals/index.ts +++ b/unraid-ui/src/components/modals/index.ts @@ -1 +1 @@ -export { default as Modals } from './ModalTarget.vue'; \ No newline at end of file +export { default as Modals } from './ModalTarget.vue'; diff --git a/unraid-ui/src/forms/ComboBoxField.vue b/unraid-ui/src/forms/ComboBoxField.vue index 9ad67fbae..5f9bef417 100644 --- a/unraid-ui/src/forms/ComboBoxField.vue +++ b/unraid-ui/src/forms/ComboBoxField.vue @@ -79,61 +79,47 @@ if (control.value.data !== undefined && control.value.data !== null) { diff --git a/unraid-ui/src/forms/ControlWrapper.vue b/unraid-ui/src/forms/ControlWrapper.vue index f0d400acb..b09a9233f 100644 --- a/unraid-ui/src/forms/ControlWrapper.vue +++ b/unraid-ui/src/forms/ControlWrapper.vue @@ -1,23 +1,22 @@ \ No newline at end of file + diff --git a/unraid-ui/src/forms/FormErrors.vue b/unraid-ui/src/forms/FormErrors.vue index b6fb905c4..ae0fa7681 100644 --- a/unraid-ui/src/forms/FormErrors.vue +++ b/unraid-ui/src/forms/FormErrors.vue @@ -15,4 +15,4 @@ const normalizedErrors = computed(() => {

{{ error }}

- \ No newline at end of file + diff --git a/unraid-ui/src/forms/HorizontalLayout.vue b/unraid-ui/src/forms/HorizontalLayout.vue index d9a0cefa5..5a6bd372e 100644 --- a/unraid-ui/src/forms/HorizontalLayout.vue +++ b/unraid-ui/src/forms/HorizontalLayout.vue @@ -14,10 +14,10 @@ * @prop cells - Available cells */ +import { useJsonFormsVisibility } from '@/forms/composables/useJsonFormsVisibility'; import type { HorizontalLayout } from '@jsonforms/core'; import { DispatchRenderer, type RendererProps } from '@jsonforms/vue'; import { computed } from 'vue'; -import { useJsonFormsVisibility } from './composables/useJsonFormsVisibility'; const props = defineProps>(); @@ -28,14 +28,12 @@ const elements = computed(() => { // Access elements from the layout object returned by the composable return layout.layout.value.uischema.elements || []; }); -