diff --git a/api/dev/Unraid.net/myservers.cfg b/api/dev/Unraid.net/myservers.cfg index 1c664dad2..1f8d0bb03 100644 --- a/api/dev/Unraid.net/myservers.cfg +++ b/api/dev/Unraid.net/myservers.cfg @@ -1,5 +1,5 @@ [api] -version="4.4.1" +version="4.8.0" extraOrigins="https://google.com,https://test.com" [local] sandbox="yes" diff --git a/api/dev/api/backup/backup-jobs.json b/api/dev/api/backup/backup-jobs.json index 3564e02b1..0637a088a 100644 --- a/api/dev/api/backup/backup-jobs.json +++ b/api/dev/api/backup/backup-jobs.json @@ -1,17 +1 @@ -[ - { - "id": "1ad3f9a9-f438-43b2-bdd5-976c7ca4b2f5", - "name": "test", - "sourcePath": "/Users/elibosley/Downloads", - "remoteName": "FlashBackup", - "destinationPath": "backup", - "schedule": "0 2 * * *", - "enabled": false, - "rcloneOptions": {}, - "createdAt": "2025-05-24T12:19:29.150Z", - "updatedAt": "2025-05-26T16:14:13.977Z", - "lastRunStatus": "Started with job ID: 34", - "currentJobId": 34, - "lastRunAt": "2025-05-26T16:14:13.977Z" - } -] \ No newline at end of file +[] \ No newline at end of file diff --git a/api/generated-schema.graphql b/api/generated-schema.graphql index b71ad6150..60f14e3c4 100644 --- a/api/generated-schema.graphql +++ b/api/generated-schema.graphql @@ -916,46 +916,37 @@ type BackupMutations { input CreateBackupJobConfigInput { name: String! - backupMode: BackupMode! = PREPROCESSING - sourcePath: String! + backupType: BackupType! = RAW remoteName: String! destinationPath: String! - schedule: String! + schedule: String enabled: Boolean! = true rcloneOptions: JSON - """Preprocessing configuration for this backup job""" - preprocessConfig: PreprocessConfigInput + """Backup configuration for this backup job""" + backupConfig: BackupConfigInput } """ -The mode of backup to perform (Raw file backup or Preprocessing-based). +Type of backup to perform (ZFS snapshot, Flash backup, Custom script, or Raw file backup) """ -enum BackupMode { +enum BackupType { + ZFS + FLASH + SCRIPT RAW - PREPROCESSING } -input PreprocessConfigInput { - """Type of preprocessing to perform""" - type: PreprocessType! - zfsConfig: ZfsPreprocessConfigInput - flashConfig: FlashPreprocessConfigInput - scriptConfig: ScriptPreprocessConfigInput - - """Timeout for preprocessing in seconds""" +input BackupConfigInput { + """Timeout for backup operation in seconds""" timeout: Float! = 3600 """Whether to cleanup on failure""" cleanupOnFailure: Boolean! = true -} - -"""Type of preprocessing to perform before backup""" -enum PreprocessType { - NONE - ZFS - FLASH - SCRIPT + zfsConfig: ZfsPreprocessConfigInput + flashConfig: FlashPreprocessConfigInput + scriptConfig: ScriptPreprocessConfigInput + rawConfig: RawBackupConfigInput } input ZfsPreprocessConfigInput { @@ -1003,17 +994,28 @@ input ScriptPreprocessConfigInput { outputPath: String! } +input RawBackupConfigInput { + """Source path to backup""" + sourcePath: String! + + """File patterns to exclude from backup""" + excludePatterns: [String!] + + """File patterns to include in backup""" + includePatterns: [String!] +} + input UpdateBackupJobConfigInput { name: String - sourcePath: String + backupType: BackupType remoteName: String destinationPath: String schedule: String enabled: Boolean rcloneOptions: JSON - """Preprocessing configuration for this backup job""" - preprocessConfig: PreprocessConfigInput + """Backup configuration for this backup job""" + backupConfig: BackupConfigInput lastRunStatus: String currentJobId: String lastRunAt: String @@ -1173,13 +1175,19 @@ type ScriptPreprocessConfig { outputPath: String! } -type PreprocessConfig { - type: PreprocessType! +type RawBackupConfig { + sourcePath: String! + excludePatterns: [String!] + includePatterns: [String!] +} + +type BackupConfig { + timeout: Float! + cleanupOnFailure: Boolean! zfsConfig: ZfsPreprocessConfig flashConfig: FlashPreprocessConfig scriptConfig: ScriptPreprocessConfig - timeout: Float! - cleanupOnFailure: Boolean! + rawConfig: RawBackupConfig } type RCloneDrive { @@ -1377,10 +1385,9 @@ type BackupJobConfig implements Node { """Human-readable name for this backup job""" name: String! - backupMode: BackupMode! - """Source path to backup""" - sourcePath: String! + """Type of backup to perform""" + backupType: BackupType! """Remote name from rclone config""" remoteName: String! @@ -1397,8 +1404,8 @@ type BackupJobConfig implements Node { """RClone options (e.g., --transfers, --checkers)""" rcloneOptions: JSON - """Preprocessing configuration for this backup job""" - preprocessConfig: PreprocessConfig + """Backup configuration for this backup job""" + backupConfig: BackupConfig """When this config was created""" createdAt: DateTime! diff --git a/api/src/unraid-api/graph/resolvers/backup/backup.model.ts b/api/src/unraid-api/graph/resolvers/backup/backup.model.ts index a60624616..9b720f900 100644 --- a/api/src/unraid-api/graph/resolvers/backup/backup.model.ts +++ b/api/src/unraid-api/graph/resolvers/backup/backup.model.ts @@ -16,24 +16,15 @@ import { import { GraphQLJSON } from 'graphql-scalars'; import { - PreprocessConfig, - PreprocessConfigInput, + BackupConfig, + BackupConfigInput, + BackupType, } from '@app/unraid-api/graph/resolvers/backup/preprocessing/preprocessing.types.js'; import { Node } from '@app/unraid-api/graph/resolvers/base.model.js'; import { RCloneJob } from '@app/unraid-api/graph/resolvers/rclone/rclone.model.js'; import { PrefixedID } from '@app/unraid-api/graph/scalars/graphql-type-prefixed-id.js'; import { DataSlice } from '@app/unraid-api/types/json-forms.js'; -export enum BackupMode { - RAW = 'RAW', - PREPROCESSING = 'PREPROCESSING', -} - -registerEnumType(BackupMode, { - name: 'BackupMode', - description: 'The mode of backup to perform (Raw file backup or Preprocessing-based).', -}); - @ObjectType({ implements: () => Node, }) @@ -98,11 +89,8 @@ export class BackupJobConfig extends Node { @Field(() => String, { description: 'Human-readable name for this backup job' }) name!: string; - @Field(() => BackupMode) - backupMode!: BackupMode; - - @Field(() => String, { description: 'Source path to backup' }) - sourcePath!: string; + @Field(() => BackupType, { description: 'Type of backup to perform' }) + backupType!: BackupType; @Field(() => String, { description: 'Remote name from rclone config' }) remoteName!: string; @@ -124,11 +112,11 @@ export class BackupJobConfig extends Node { }) rcloneOptions?: Record; - @Field(() => PreprocessConfig, { - description: 'Preprocessing configuration for this backup job', + @Field(() => BackupConfig, { + description: 'Backup configuration for this backup job', nullable: true, }) - preprocessConfig?: PreprocessConfig; + backupConfig?: BackupConfig; @Field(() => Date, { description: 'When this config was created' }) createdAt!: Date; @@ -150,77 +138,17 @@ export class BackupJobConfig extends Node { } @InputType() -export class CreateBackupJobConfigInput { - @Field(() => String) - @IsString() - @IsNotEmpty() - name!: string; - - @Field(() => BackupMode, { defaultValue: BackupMode.PREPROCESSING }) - @IsEnum(BackupMode) - @IsNotEmpty() - backupMode?: BackupMode; - - @Field(() => String) - @IsString() - @ValidateIf((o) => o.backupMode === BackupMode.RAW) - @IsNotEmpty({ message: 'sourcePath should not be empty when backupMode is RAW' }) - sourcePath!: string; - - @Field(() => String) - @IsString() - @IsNotEmpty() - remoteName!: string; - - @Field(() => String) - @IsString() - @IsNotEmpty() - destinationPath!: string; - - @Field(() => String) - @IsString() - @IsNotEmpty() - @Matches( - /^(\*|[0-5]?\d)(\s+(\*|[0-1]?\d|2[0-3]))(\s+(\*|[1-2]?\d|3[0-1]))(\s+(\*|[1-9]|1[0-2]))(\s+(\*|[0-6]))$/, - { - message: 'schedule must be a valid cron expression', - } - ) - schedule!: string; - - @Field(() => Boolean, { defaultValue: true }) - @IsBoolean() - enabled!: boolean; - - @Field(() => GraphQLJSON, { nullable: true }) - @IsOptional() - @IsObject() - rcloneOptions?: Record; - - @Field(() => PreprocessConfigInput, { - description: 'Preprocessing configuration for this backup job', - nullable: true, - }) - @IsOptional() - @ValidateIf((o) => o.backupMode === BackupMode.PREPROCESSING) - @ValidateNested() - @Type(() => PreprocessConfigInput) - preprocessConfig?: PreprocessConfigInput; -} - -@InputType() -export class UpdateBackupJobConfigInput { +export class BaseBackupJobConfigInput { @Field(() => String, { nullable: true }) @IsOptional() @IsString() @IsNotEmpty() name?: string; - @Field(() => String, { nullable: true }) + @Field(() => BackupType, { nullable: true }) @IsOptional() - @IsString() - @IsNotEmpty() - sourcePath?: string; + @IsEnum(BackupType) + backupType?: BackupType; @Field(() => String, { nullable: true }) @IsOptional() @@ -237,7 +165,7 @@ export class UpdateBackupJobConfigInput { @Field(() => String, { nullable: true }) @IsOptional() @IsString() - @IsNotEmpty() + @ValidateIf((o) => o.schedule && o.schedule.length > 0) @Matches( /^(\*|[0-5]?\d)(\s+(\*|[0-1]?\d|2[0-3]))(\s+(\*|[1-2]?\d|3[0-1]))(\s+(\*|[1-9]|1[0-2]))(\s+(\*|[0-6]))$/, { @@ -256,15 +184,46 @@ export class UpdateBackupJobConfigInput { @IsObject() rcloneOptions?: Record; - @Field(() => PreprocessConfigInput, { - description: 'Preprocessing configuration for this backup job', + @Field(() => BackupConfigInput, { + description: 'Backup configuration for this backup job', nullable: true, }) @IsOptional() @ValidateNested() - @Type(() => PreprocessConfigInput) - preprocessConfig?: PreprocessConfigInput; + @Type(() => BackupConfigInput) + backupConfig?: BackupConfigInput; +} +@InputType() +export class CreateBackupJobConfigInput extends BaseBackupJobConfigInput { + @Field(() => String) + @IsString() + @IsNotEmpty() + declare name: string; + + @Field(() => BackupType, { defaultValue: BackupType.RAW }) + @IsEnum(BackupType) + @IsNotEmpty() + declare backupType: BackupType; + + @Field(() => String) + @IsString() + @IsNotEmpty() + declare remoteName: string; + + @Field(() => String) + @IsString() + @IsNotEmpty() + declare destinationPath: string; + + @Field(() => Boolean, { defaultValue: true }) + @IsBoolean() + @ValidateIf((o) => o.schedule && o.schedule.length > 0) + declare enabled: boolean; +} + +@InputType() +export class UpdateBackupJobConfigInput extends BaseBackupJobConfigInput { @Field(() => String, { nullable: true }) @IsOptional() @IsString() diff --git a/api/src/unraid-api/graph/resolvers/backup/jsonforms/backup-jsonforms-config.ts b/api/src/unraid-api/graph/resolvers/backup/jsonforms/backup-jsonforms-config.ts index b1eab79b9..a33aad125 100644 --- a/api/src/unraid-api/graph/resolvers/backup/jsonforms/backup-jsonforms-config.ts +++ b/api/src/unraid-api/graph/resolvers/backup/jsonforms/backup-jsonforms-config.ts @@ -3,7 +3,7 @@ import { JsonSchema7, RuleEffect } from '@jsonforms/core'; import type { RCloneRemote } from '@app/unraid-api/graph/resolvers/rclone/rclone.model.js'; import type { DataSlice, SettingSlice, UIElement } from '@app/unraid-api/types/json-forms.js'; -import { BackupMode } from '@app/unraid-api/graph/resolvers/backup/backup.model.js'; +import { BackupType } from '@app/unraid-api/graph/resolvers/backup/preprocessing/preprocessing.types.js'; import { createLabeledControl } from '@app/unraid-api/graph/utils/form-utils.js'; import { mergeSettingSlices } from '@app/unraid-api/types/json-forms.js'; @@ -19,27 +19,6 @@ function getBasicBackupConfigSlice({ remotes = [] }: { remotes?: RCloneRemote[] }, }), - createLabeledControl({ - scope: '#/properties/backupMode', - label: 'Backup Mode', - description: 'Choose between preprocessing-based backup or raw file backup', - controlOptions: { - suggestions: [ - { - value: BackupMode.PREPROCESSING, - label: 'Preprocessing Backup', - tooltip: - 'Advanced backup using ZFS snapshots, flash drive backup, or custom scripts to prepare data before transfer', - }, - { - value: BackupMode.RAW, - label: 'Raw File Backup', - tooltip: 'Simple folder-to-folder backup with direct file/directory paths', - }, - ], - }, - }), - createLabeledControl({ scope: '#/properties/remoteName', label: 'Remote Configuration', @@ -66,11 +45,16 @@ function getBasicBackupConfigSlice({ remotes = [] }: { remotes?: RCloneRemote[] scope: '#/properties/schedule', label: 'Schedule (Cron Expression)', description: - 'When to run this backup job. Examples: "0 2 * * *" (daily at 2AM), "0 2 * * 0" (weekly on Sunday at 2AM)', + 'When to run this backup job. Leave empty for manual execution only. Examples: "0 2 * * *" (daily at 2AM), "0 2 * * 0" (weekly on Sunday at 2AM)', controlOptions: { - placeholder: '0 2 * * *', + placeholder: 'Leave empty for manual backup', format: 'string', suggestions: [ + { + value: '', + label: 'Manual Only', + tooltip: 'No automatic schedule - run manually only', + }, { value: '0 2 * * *', label: 'Daily at 2:00 AM', @@ -112,6 +96,16 @@ function getBasicBackupConfigSlice({ remotes = [] }: { remotes?: RCloneRemote[] controlOptions: { toggle: true, }, + rule: { + effect: RuleEffect.SHOW, + condition: { + scope: '#/properties/schedule', + schema: { + type: 'string', + minLength: 1, + }, + } as SchemaBasedCondition, + }, }), ]; @@ -123,13 +117,6 @@ function getBasicBackupConfigSlice({ remotes = [] }: { remotes?: RCloneRemote[] minLength: 1, maxLength: 100, }, - backupMode: { - type: 'string', - title: 'Backup Mode', - description: 'Type of backup to perform', - enum: [BackupMode.PREPROCESSING, BackupMode.RAW], - default: BackupMode.PREPROCESSING, - }, remoteName: { type: 'string', title: 'Remote Name', @@ -145,10 +132,7 @@ function getBasicBackupConfigSlice({ remotes = [] }: { remotes?: RCloneRemote[] schedule: { type: 'string', title: 'Cron Schedule', - description: 'Cron schedule expression', - pattern: '^\\s*(\\S+\\s+){4}\\S+\\s*$', - errorMessage: - 'Please enter a valid cron expression (5 fields: minute hour day month weekday)', + description: 'Cron schedule expression (empty for manual execution)', }, enabled: { type: 'boolean', @@ -178,99 +162,473 @@ function getBasicBackupConfigSlice({ remotes = [] }: { remotes?: RCloneRemote[] }; } -function getRawBackupConfigSlice(): SettingSlice { - const rawConfigElements: UIElement[] = [ +function getBackupTypeConfigSlice(): SettingSlice { + const backupTypeElements: UIElement[] = [ { type: 'Label', - text: 'Raw Backup Configuration', + text: 'Backup Configuration', options: { - description: 'Configure direct file/folder backup with manual source paths.', + description: 'Configure the specific settings for your chosen backup type.', }, } as LabelElement, createLabeledControl({ - scope: '#/properties/rawConfig/properties/sourcePath', - label: 'Source Path', - description: 'The local path to backup (e.g., /mnt/user/Documents)', + scope: '#/properties/backupType', + label: 'Backup Type', + description: 'Select the type of backup to perform', controlOptions: { - placeholder: '/mnt/user/', - format: 'string', + suggestions: [ + { + value: BackupType.ZFS, + label: 'ZFS Snapshot', + tooltip: 'Create ZFS snapshot and stream it', + }, + { + value: BackupType.FLASH, + label: 'Flash Backup', + tooltip: 'Backup Unraid flash drive with git history', + }, + { + value: BackupType.SCRIPT, + label: 'Custom Script', + tooltip: 'Run custom script before backup', + }, + { + value: BackupType.RAW, + label: 'Raw File Backup', + tooltip: 'Direct file/folder backup', + }, + ], }, }), createLabeledControl({ - scope: '#/properties/rawConfig/properties/excludePatterns', - label: 'Exclude Patterns', - description: 'File patterns to exclude from backup (one per line, supports wildcards)', + scope: '#/properties/backupConfig/properties/timeout', + label: 'Timeout (seconds)', + description: 'Maximum time to wait for backup operation to complete (default: 3600 seconds)', controlOptions: { - multi: true, - placeholder: '*.tmp', - format: 'string', + placeholder: '3600', + format: 'number', }, }), createLabeledControl({ - scope: '#/properties/rawConfig/properties/includePatterns', - label: 'Include Patterns', - description: 'File patterns to specifically include (one per line, supports wildcards)', + scope: '#/properties/backupConfig/properties/cleanupOnFailure', + label: 'Cleanup on Failure', + description: 'Whether to clean up backup artifacts if the backup fails', controlOptions: { - multi: true, - placeholder: '*.pdf', - format: 'string', + toggle: true, }, }), + + // Raw Backup Configuration + { + type: 'VerticalLayout', + rule: { + effect: RuleEffect.SHOW, + condition: { + scope: '#/properties/backupType', + schema: { const: BackupType.RAW }, + } as SchemaBasedCondition, + }, + elements: [ + { + type: 'Label', + text: 'Raw Backup Configuration', + options: { + description: 'Configure direct file/folder backup settings.', + }, + } as LabelElement, + + createLabeledControl({ + scope: '#/properties/backupConfig/properties/rawConfig/properties/sourcePath', + label: 'Source Path', + description: 'The local path to backup (e.g., /mnt/user/Documents)', + controlOptions: { + placeholder: '/mnt/user/', + format: 'string', + }, + }), + + createLabeledControl({ + scope: '#/properties/backupConfig/properties/rawConfig/properties/excludePatterns', + label: 'Exclude Patterns', + description: + 'File patterns to exclude from backup (one per line, supports wildcards)', + controlOptions: { + multi: true, + placeholder: '*.tmp', + format: 'string', + }, + }), + + createLabeledControl({ + scope: '#/properties/backupConfig/properties/rawConfig/properties/includePatterns', + label: 'Include Patterns', + description: + 'File patterns to specifically include (one per line, supports wildcards)', + controlOptions: { + multi: true, + placeholder: '*.pdf', + format: 'string', + }, + }), + ], + }, + + // ZFS Configuration + { + type: 'VerticalLayout', + rule: { + effect: RuleEffect.SHOW, + condition: { + scope: '#/properties/backupType', + schema: { const: BackupType.ZFS }, + } as SchemaBasedCondition, + }, + elements: [ + { + type: 'Label', + text: 'ZFS Configuration', + options: { + description: 'Configure ZFS snapshot settings for backup.', + }, + } as LabelElement, + + createLabeledControl({ + scope: '#/properties/backupConfig/properties/zfsConfig/properties/poolName', + label: 'ZFS Pool Name', + description: 'Name of the ZFS pool containing the dataset', + controlOptions: { + placeholder: 'tank', + format: 'string', + }, + }), + + createLabeledControl({ + scope: '#/properties/backupConfig/properties/zfsConfig/properties/datasetName', + label: 'Dataset Name', + description: 'Name of the ZFS dataset to snapshot', + controlOptions: { + placeholder: 'data/documents', + format: 'string', + }, + }), + + createLabeledControl({ + scope: '#/properties/backupConfig/properties/zfsConfig/properties/snapshotPrefix', + label: 'Snapshot Prefix', + description: 'Prefix for snapshot names (default: backup)', + controlOptions: { + placeholder: 'backup', + format: 'string', + }, + }), + + createLabeledControl({ + scope: '#/properties/backupConfig/properties/zfsConfig/properties/cleanupSnapshots', + label: 'Cleanup Snapshots', + description: 'Whether to clean up snapshots after backup', + controlOptions: { + toggle: true, + }, + }), + + createLabeledControl({ + scope: '#/properties/backupConfig/properties/zfsConfig/properties/retainSnapshots', + label: 'Retain Snapshots', + description: 'Number of snapshots to retain (0 = keep all)', + controlOptions: { + placeholder: '5', + format: 'number', + }, + }), + ], + }, + + // Flash Configuration + { + type: 'VerticalLayout', + rule: { + effect: RuleEffect.SHOW, + condition: { + scope: '#/properties/backupType', + schema: { const: BackupType.FLASH }, + } as SchemaBasedCondition, + }, + elements: [ + { + type: 'Label', + text: 'Flash Backup Configuration', + options: { + description: 'Configure Unraid flash drive backup settings.', + }, + } as LabelElement, + + createLabeledControl({ + scope: '#/properties/backupConfig/properties/flashConfig/properties/flashPath', + label: 'Flash Path', + description: 'Path to the Unraid flash drive (default: /boot)', + controlOptions: { + placeholder: '/boot', + format: 'string', + }, + }), + + createLabeledControl({ + scope: '#/properties/backupConfig/properties/flashConfig/properties/includeGitHistory', + label: 'Include Git History', + description: 'Whether to include git history in the backup', + controlOptions: { + toggle: true, + }, + }), + + createLabeledControl({ + scope: '#/properties/backupConfig/properties/flashConfig/properties/additionalPaths', + label: 'Additional Paths', + description: 'Additional paths to include in flash backup (one per line)', + controlOptions: { + multi: true, + placeholder: '/boot/config/plugins', + format: 'string', + }, + }), + ], + }, + + // Script Configuration + { + type: 'VerticalLayout', + rule: { + effect: RuleEffect.SHOW, + condition: { + scope: '#/properties/backupType', + schema: { const: BackupType.SCRIPT }, + } as SchemaBasedCondition, + }, + elements: [ + { + type: 'Label', + text: 'Custom Script Configuration', + options: { + description: 'Configure custom script execution settings.', + }, + } as LabelElement, + + createLabeledControl({ + scope: '#/properties/backupConfig/properties/scriptConfig/properties/scriptPath', + label: 'Script Path', + description: 'Full path to the script to execute', + controlOptions: { + placeholder: '/mnt/user/scripts/backup-prep.sh', + format: 'string', + }, + }), + + createLabeledControl({ + scope: '#/properties/backupConfig/properties/scriptConfig/properties/scriptArgs', + label: 'Script Arguments', + description: 'Arguments to pass to the script (one per line)', + controlOptions: { + multi: true, + placeholder: '--verbose', + format: 'string', + }, + }), + + createLabeledControl({ + scope: '#/properties/backupConfig/properties/scriptConfig/properties/workingDirectory', + label: 'Working Directory', + description: 'Working directory for script execution', + controlOptions: { + placeholder: '/tmp', + format: 'string', + }, + }), + + createLabeledControl({ + scope: '#/properties/backupConfig/properties/scriptConfig/properties/outputPath', + label: 'Output Path', + description: 'Path where script should write output files for backup', + controlOptions: { + placeholder: '/tmp/backup-output', + format: 'string', + }, + }), + ], + }, ]; - const rawConfigProperties: Record = { - rawConfig: { + const backupConfigProperties: Record = { + backupType: { + type: 'string', + title: 'Backup Type', + description: 'Type of backup to perform', + enum: [BackupType.ZFS, BackupType.FLASH, BackupType.SCRIPT, BackupType.RAW], + default: BackupType.ZFS, + }, + backupConfig: { type: 'object', - title: 'Raw Backup Configuration', - description: 'Configuration for direct file backup', + title: 'Backup Configuration', + description: 'Configuration for backup operation', properties: { - sourcePath: { - type: 'string', - title: 'Source Path', - description: 'Source path to backup', - minLength: 1, + timeout: { + type: 'integer', + title: 'Timeout', + description: 'Timeout in seconds for backup operation', + minimum: 30, + maximum: 86400, + default: 3600, }, - excludePatterns: { - type: 'array', - title: 'Exclude Patterns', - description: 'Patterns to exclude from backup', - items: { - type: 'string', - }, - default: [], + cleanupOnFailure: { + type: 'boolean', + title: 'Cleanup on Failure', + description: 'Clean up backup artifacts on failure', + default: true, }, - includePatterns: { - type: 'array', - title: 'Include Patterns', - description: 'Patterns to include in backup', - items: { - type: 'string', + rawConfig: { + type: 'object', + title: 'Raw Backup Configuration', + properties: { + sourcePath: { + type: 'string', + title: 'Source Path', + description: 'Source path to backup', + minLength: 1, + }, + excludePatterns: { + type: 'array', + title: 'Exclude Patterns', + description: 'Patterns to exclude from backup', + items: { + type: 'string', + }, + default: [], + }, + includePatterns: { + type: 'array', + title: 'Include Patterns', + description: 'Patterns to include in backup', + items: { + type: 'string', + }, + default: [], + }, }, - default: [], + required: ['sourcePath'], + }, + zfsConfig: { + type: 'object', + title: 'ZFS Configuration', + properties: { + poolName: { + type: 'string', + title: 'Pool Name', + description: 'ZFS pool name', + minLength: 1, + }, + datasetName: { + type: 'string', + title: 'Dataset Name', + description: 'ZFS dataset name', + minLength: 1, + }, + snapshotPrefix: { + type: 'string', + title: 'Snapshot Prefix', + description: 'Prefix for snapshot names', + default: 'backup', + }, + cleanupSnapshots: { + type: 'boolean', + title: 'Cleanup Snapshots', + description: 'Clean up snapshots after backup', + default: true, + }, + retainSnapshots: { + type: 'integer', + title: 'Retain Snapshots', + description: 'Number of snapshots to retain', + minimum: 0, + default: 5, + }, + }, + required: ['poolName', 'datasetName'], + }, + flashConfig: { + type: 'object', + title: 'Flash Configuration', + properties: { + flashPath: { + type: 'string', + title: 'Flash Path', + description: 'Path to flash drive', + default: '/boot', + }, + includeGitHistory: { + type: 'boolean', + title: 'Include Git History', + description: 'Include git history in backup', + default: true, + }, + additionalPaths: { + type: 'array', + title: 'Additional Paths', + description: 'Additional paths to include', + items: { + type: 'string', + }, + default: [], + }, + }, + }, + scriptConfig: { + type: 'object', + title: 'Script Configuration', + properties: { + scriptPath: { + type: 'string', + title: 'Script Path', + description: 'Path to script file', + minLength: 1, + }, + scriptArgs: { + type: 'array', + title: 'Script Arguments', + description: 'Arguments for script', + items: { + type: 'string', + }, + default: [], + }, + workingDirectory: { + type: 'string', + title: 'Working Directory', + description: 'Working directory for script', + default: '/tmp', + }, + outputPath: { + type: 'string', + title: 'Output Path', + description: 'Path for script output', + minLength: 1, + }, + }, + required: ['scriptPath', 'outputPath'], }, }, - required: ['sourcePath'], }, }; - const conditionalLayoutElement: UIElement = { + const verticalLayoutElement: UIElement = { type: 'VerticalLayout', - rule: { - effect: RuleEffect.SHOW, - condition: { - scope: '#/properties/backupMode', - schema: { const: BackupMode.RAW }, - } as SchemaBasedCondition, - }, - elements: rawConfigElements, + elements: backupTypeElements, }; return { - properties: rawConfigProperties, - elements: [conditionalLayoutElement], + properties: backupConfigProperties, + elements: [verticalLayoutElement], }; } @@ -382,393 +740,6 @@ function getAdvancedBackupConfigSlice(): SettingSlice { }; } -function getPreprocessingConfigSlice(): SettingSlice { - const preprocessingElements: UIElement[] = [ - { - type: 'Label', - text: 'Preprocessing Configuration', - options: { - description: - 'Configure preprocessing steps to run before backup (e.g., ZFS snapshots, Flash backup, custom scripts).', - }, - } as LabelElement, - - createLabeledControl({ - scope: '#/properties/preprocessConfig/properties/type', - label: 'Preprocessing Type', - description: 'Select the type of preprocessing to perform before backup', - controlOptions: { - suggestions: [ - { - value: 'ZFS', - label: 'ZFS Snapshot', - tooltip: 'Create ZFS snapshot and stream it', - }, - { - value: 'FLASH', - label: 'Flash Backup', - tooltip: 'Backup Unraid flash drive with git history', - }, - { - value: 'SCRIPT', - label: 'Custom Script', - tooltip: 'Run custom script before backup', - }, - ], - }, - }), - - createLabeledControl({ - scope: '#/properties/preprocessConfig/properties/timeout', - label: 'Timeout (seconds)', - description: 'Maximum time to wait for preprocessing to complete (default: 300 seconds)', - controlOptions: { - placeholder: '300', - format: 'number', - }, - }), - - createLabeledControl({ - scope: '#/properties/preprocessConfig/properties/cleanupOnFailure', - label: 'Cleanup on Failure', - description: 'Whether to clean up preprocessing artifacts if the backup fails', - controlOptions: { - toggle: true, - }, - }), - - // ZFS Configuration - { - type: 'VerticalLayout', - rule: { - effect: RuleEffect.SHOW, - condition: { - scope: '#/properties/preprocessConfig/properties/type', - schema: { const: 'ZFS' }, - } as SchemaBasedCondition, - }, - elements: [ - { - type: 'Label', - text: 'ZFS Configuration', - options: { - description: 'Configure ZFS snapshot settings for preprocessing.', - }, - } as LabelElement, - - createLabeledControl({ - scope: '#/properties/preprocessConfig/properties/zfsConfig/properties/poolName', - label: 'ZFS Pool Name', - description: 'Name of the ZFS pool containing the dataset', - controlOptions: { - placeholder: 'tank', - format: 'string', - }, - }), - - createLabeledControl({ - scope: '#/properties/preprocessConfig/properties/zfsConfig/properties/datasetName', - label: 'Dataset Name', - description: 'Name of the ZFS dataset to snapshot', - controlOptions: { - placeholder: 'data/documents', - format: 'string', - }, - }), - - createLabeledControl({ - scope: '#/properties/preprocessConfig/properties/zfsConfig/properties/snapshotPrefix', - label: 'Snapshot Prefix', - description: 'Prefix for snapshot names (default: backup)', - controlOptions: { - placeholder: 'backup', - format: 'string', - }, - }), - - createLabeledControl({ - scope: '#/properties/preprocessConfig/properties/zfsConfig/properties/cleanupSnapshots', - label: 'Cleanup Snapshots', - description: 'Whether to clean up snapshots after backup', - controlOptions: { - toggle: true, - }, - }), - - createLabeledControl({ - scope: '#/properties/preprocessConfig/properties/zfsConfig/properties/retainSnapshots', - label: 'Retain Snapshots', - description: 'Number of snapshots to retain (0 = keep all)', - controlOptions: { - placeholder: '5', - format: 'number', - }, - }), - ], - }, - - // Flash Configuration - { - type: 'VerticalLayout', - rule: { - effect: RuleEffect.SHOW, - condition: { - scope: '#/properties/preprocessConfig/properties/type', - schema: { const: 'FLASH' }, - } as SchemaBasedCondition, - }, - elements: [ - { - type: 'Label', - text: 'Flash Backup Configuration', - options: { - description: 'Configure Unraid flash drive backup settings.', - }, - } as LabelElement, - - createLabeledControl({ - scope: '#/properties/preprocessConfig/properties/flashConfig/properties/flashPath', - label: 'Flash Path', - description: 'Path to the Unraid flash drive (default: /boot)', - controlOptions: { - placeholder: '/boot', - format: 'string', - }, - }), - - createLabeledControl({ - scope: '#/properties/preprocessConfig/properties/flashConfig/properties/includeGitHistory', - label: 'Include Git History', - description: 'Whether to include git history in the backup', - controlOptions: { - toggle: true, - }, - }), - - createLabeledControl({ - scope: '#/properties/preprocessConfig/properties/flashConfig/properties/additionalPaths', - label: 'Additional Paths', - description: 'Additional paths to include in flash backup (one per line)', - controlOptions: { - multi: true, - placeholder: '/boot/config/plugins', - format: 'string', - }, - }), - ], - }, - - // Script Configuration - { - type: 'VerticalLayout', - rule: { - effect: RuleEffect.SHOW, - condition: { - scope: '#/properties/preprocessConfig/properties/type', - schema: { const: 'SCRIPT' }, - } as SchemaBasedCondition, - }, - elements: [ - { - type: 'Label', - text: 'Custom Script Configuration', - options: { - description: 'Configure custom script execution settings.', - }, - } as LabelElement, - - createLabeledControl({ - scope: '#/properties/preprocessConfig/properties/scriptConfig/properties/scriptPath', - label: 'Script Path', - description: 'Full path to the script to execute', - controlOptions: { - placeholder: '/mnt/user/scripts/backup-prep.sh', - format: 'string', - }, - }), - - createLabeledControl({ - scope: '#/properties/preprocessConfig/properties/scriptConfig/properties/scriptArgs', - label: 'Script Arguments', - description: 'Arguments to pass to the script (one per line)', - controlOptions: { - multi: true, - placeholder: '--verbose', - format: 'string', - }, - }), - - createLabeledControl({ - scope: '#/properties/preprocessConfig/properties/scriptConfig/properties/workingDirectory', - label: 'Working Directory', - description: 'Working directory for script execution', - controlOptions: { - placeholder: '/tmp', - format: 'string', - }, - }), - - createLabeledControl({ - scope: '#/properties/preprocessConfig/properties/scriptConfig/properties/outputPath', - label: 'Output Path', - description: 'Path where script should write output files for backup', - controlOptions: { - placeholder: '/tmp/backup-output', - format: 'string', - }, - }), - ], - }, - ]; - - const preprocessingProperties: Record = { - preprocessConfig: { - type: 'object', - title: 'Preprocessing Configuration', - description: 'Configuration for preprocessing steps before backup', - properties: { - type: { - type: 'string', - title: 'Preprocessing Type', - description: 'Type of preprocessing to perform', - enum: ['ZFS', 'FLASH', 'SCRIPT'], - }, - timeout: { - type: 'integer', - title: 'Timeout', - description: 'Timeout in seconds for preprocessing', - minimum: 30, - maximum: 3600, - default: 300, - }, - cleanupOnFailure: { - type: 'boolean', - title: 'Cleanup on Failure', - description: 'Clean up preprocessing artifacts on failure', - default: true, - }, - zfsConfig: { - type: 'object', - title: 'ZFS Configuration', - properties: { - poolName: { - type: 'string', - title: 'Pool Name', - description: 'ZFS pool name', - minLength: 1, - }, - datasetName: { - type: 'string', - title: 'Dataset Name', - description: 'ZFS dataset name', - minLength: 1, - }, - snapshotPrefix: { - type: 'string', - title: 'Snapshot Prefix', - description: 'Prefix for snapshot names', - default: 'backup', - }, - cleanupSnapshots: { - type: 'boolean', - title: 'Cleanup Snapshots', - description: 'Clean up snapshots after backup', - default: true, - }, - retainSnapshots: { - type: 'integer', - title: 'Retain Snapshots', - description: 'Number of snapshots to retain', - minimum: 0, - default: 5, - }, - }, - required: ['poolName', 'datasetName'], - }, - flashConfig: { - type: 'object', - title: 'Flash Configuration', - properties: { - flashPath: { - type: 'string', - title: 'Flash Path', - description: 'Path to flash drive', - default: '/boot', - }, - includeGitHistory: { - type: 'boolean', - title: 'Include Git History', - description: 'Include git history in backup', - default: true, - }, - additionalPaths: { - type: 'array', - title: 'Additional Paths', - description: 'Additional paths to include', - items: { - type: 'string', - }, - default: [], - }, - }, - }, - scriptConfig: { - type: 'object', - title: 'Script Configuration', - properties: { - scriptPath: { - type: 'string', - title: 'Script Path', - description: 'Path to script file', - minLength: 1, - }, - scriptArgs: { - type: 'array', - title: 'Script Arguments', - description: 'Arguments for script', - items: { - type: 'string', - }, - default: [], - }, - workingDirectory: { - type: 'string', - title: 'Working Directory', - description: 'Working directory for script', - default: '/tmp', - }, - outputPath: { - type: 'string', - title: 'Output Path', - description: 'Path for script output', - minLength: 1, - }, - }, - required: ['scriptPath', 'outputPath'], - }, - }, - required: ['type'], - }, - }; - - const conditionalLayoutElement: UIElement = { - type: 'VerticalLayout', - rule: { - effect: RuleEffect.SHOW, - condition: { - scope: '#/properties/backupMode', - schema: { const: BackupMode.PREPROCESSING }, - } as SchemaBasedCondition, - }, - elements: preprocessingElements, - }; - - return { - properties: preprocessingProperties, - elements: [conditionalLayoutElement], - }; -} - export function buildBackupJobConfigSchema({ remotes = [] }: { remotes?: RCloneRemote[] }): { dataSchema: { properties: DataSlice; type: 'object' }; uiSchema: Layout; @@ -778,11 +749,8 @@ export function buildBackupJobConfigSchema({ remotes = [] }: { remotes?: RCloneR const basicSlice = getBasicBackupConfigSlice({ remotes }); slicesToMerge.push(basicSlice); - const preprocessingSlice = getPreprocessingConfigSlice(); - slicesToMerge.push(preprocessingSlice); - - const rawBackupSlice = getRawBackupConfigSlice(); - slicesToMerge.push(rawBackupSlice); + const backupTypeSlice = getBackupTypeConfigSlice(); + slicesToMerge.push(backupTypeSlice); const advancedSlice = getAdvancedBackupConfigSlice(); if (Object.keys(advancedSlice.properties).length > 0) { @@ -797,10 +765,10 @@ export function buildBackupJobConfigSchema({ remotes = [] }: { remotes?: RCloneR }; const steps = [ - { label: 'Backup Configuration', description: 'Basic backup job settings and mode selection' }, + { label: 'Backup Configuration', description: 'Basic backup job settings and type selection' }, { - label: 'Source Configuration', - description: 'Configure backup source (preprocessing or raw files)', + label: 'Backup Type Configuration', + description: 'Configure specific settings for your chosen backup type', }, { label: 'Advanced Options', description: 'RClone-specific settings' }, ]; @@ -809,7 +777,7 @@ export function buildBackupJobConfigSchema({ remotes = [] }: { remotes?: RCloneR const step1WrapperLayout: UIElement = { type: 'VerticalLayout', - elements: [...(preprocessingSlice.elements || []), ...(rawBackupSlice.elements || [])], + elements: [...(backupTypeSlice.elements || [])], options: { step: 1 }, }; diff --git a/api/src/unraid-api/graph/resolvers/backup/preprocessing/preprocessing.types.ts b/api/src/unraid-api/graph/resolvers/backup/preprocessing/preprocessing.types.ts index 90a37fb26..0143d8ddd 100644 --- a/api/src/unraid-api/graph/resolvers/backup/preprocessing/preprocessing.types.ts +++ b/api/src/unraid-api/graph/resolvers/backup/preprocessing/preprocessing.types.ts @@ -2,6 +2,7 @@ import { Field, InputType, ObjectType, registerEnumType } from '@nestjs/graphql' import { Type } from 'class-transformer'; import { + IsArray, IsBoolean, IsEnum, IsNotEmpty, @@ -14,16 +15,17 @@ import { } from 'class-validator'; import { GraphQLJSON } from 'graphql-scalars'; -export enum PreprocessType { - NONE = 'none', - ZFS = 'zfs', - FLASH = 'flash', - SCRIPT = 'script', +export enum BackupType { + ZFS = 'ZFS', + FLASH = 'FLASH', + SCRIPT = 'SCRIPT', + RAW = 'RAW', } -registerEnumType(PreprocessType, { - name: 'PreprocessType', - description: 'Type of preprocessing to perform before backup', +registerEnumType(BackupType, { + name: 'BackupType', + description: + 'Type of backup to perform (ZFS snapshot, Flash backup, Custom script, or Raw file backup)', }); @InputType() @@ -151,46 +153,80 @@ export class ScriptPreprocessConfig { } @InputType() -export class PreprocessConfigInput { - @Field(() => PreprocessType, { description: 'Type of preprocessing to perform' }) - @IsEnum(PreprocessType) - type!: PreprocessType; +export class RawBackupConfigInput { + @Field(() => String, { description: 'Source path to backup' }) + @IsString() + @IsNotEmpty() + sourcePath!: string; + + @Field(() => [String], { description: 'File patterns to exclude from backup', nullable: true }) + @IsOptional() + @IsArray() + excludePatterns?: string[]; + + @Field(() => [String], { description: 'File patterns to include in backup', nullable: true }) + @IsOptional() + @IsArray() + includePatterns?: string[]; +} + +@ObjectType() +export class RawBackupConfig { + @Field(() => String) + sourcePath!: string; + + @Field(() => [String], { nullable: true }) + excludePatterns?: string[]; + + @Field(() => [String], { nullable: true }) + includePatterns?: string[]; +} + +@InputType() +export class BackupConfigInput { + @Field(() => Number, { description: 'Timeout for backup operation in seconds', defaultValue: 3600 }) + @IsOptional() + @IsNumber() + @Min(1) + timeout?: number; + + @Field(() => Boolean, { description: 'Whether to cleanup on failure', defaultValue: true }) + @IsOptional() + @IsBoolean() + cleanupOnFailure?: boolean; @Field(() => ZfsPreprocessConfigInput, { nullable: true }) @IsOptional() - @ValidateIf((o) => o.type === PreprocessType.ZFS) @ValidateNested() @Type(() => ZfsPreprocessConfigInput) zfsConfig?: ZfsPreprocessConfigInput; @Field(() => FlashPreprocessConfigInput, { nullable: true }) @IsOptional() - @ValidateIf((o) => o.type === PreprocessType.FLASH) @ValidateNested() @Type(() => FlashPreprocessConfigInput) flashConfig?: FlashPreprocessConfigInput; @Field(() => ScriptPreprocessConfigInput, { nullable: true }) @IsOptional() - @ValidateIf((o) => o.type === PreprocessType.SCRIPT) @ValidateNested() @Type(() => ScriptPreprocessConfigInput) scriptConfig?: ScriptPreprocessConfigInput; - @Field(() => Number, { description: 'Timeout for preprocessing in seconds', defaultValue: 3600 }) - @IsNumber() - @Min(1) - timeout!: number; - - @Field(() => Boolean, { description: 'Whether to cleanup on failure', defaultValue: true }) - @IsBoolean() - cleanupOnFailure!: boolean; + @Field(() => RawBackupConfigInput, { nullable: true }) + @IsOptional() + @ValidateNested() + @Type(() => RawBackupConfigInput) + rawConfig?: RawBackupConfigInput; } @ObjectType() -export class PreprocessConfig { - @Field(() => PreprocessType) - type!: PreprocessType; +export class BackupConfig { + @Field(() => Number) + timeout!: number; + + @Field(() => Boolean) + cleanupOnFailure!: boolean; @Field(() => ZfsPreprocessConfig, { nullable: true }) zfsConfig?: ZfsPreprocessConfig; @@ -201,11 +237,8 @@ export class PreprocessConfig { @Field(() => ScriptPreprocessConfig, { nullable: true }) scriptConfig?: ScriptPreprocessConfig; - @Field(() => Number) - timeout!: number; - - @Field(() => Boolean) - cleanupOnFailure!: boolean; + @Field(() => RawBackupConfig, { nullable: true }) + rawConfig?: RawBackupConfig; } export interface PreprocessResult { @@ -222,8 +255,14 @@ export interface StreamingJobInfo { jobId: string; processId: number; startTime: Date; - type: PreprocessType; + type: BackupType; status: 'running' | 'completed' | 'failed' | 'cancelled'; progress?: number; error?: string; } + +// Type aliases for backward compatibility +export type PreprocessType = BackupType; +export const PreprocessType = BackupType; +export type PreprocessConfig = BackupConfig; +export type PreprocessConfigInput = BackupConfigInput; diff --git a/api/src/unraid-api/graph/resolvers/rclone/rclone.service.ts b/api/src/unraid-api/graph/resolvers/rclone/rclone.service.ts index 1e70a17ea..3c89631e7 100644 --- a/api/src/unraid-api/graph/resolvers/rclone/rclone.service.ts +++ b/api/src/unraid-api/graph/resolvers/rclone/rclone.service.ts @@ -11,7 +11,7 @@ import { RCloneJob, RCloneRemote } from '@app/unraid-api/graph/resolvers/rclone/ * Types for rclone backup configuration UI */ export interface RcloneBackupConfigValues { - configStep: number; + configStep: { current: number; total: number }; showAdvanced: boolean; name?: string; type?: string; @@ -77,7 +77,7 @@ export class RCloneService { */ async getCurrentSettings(): Promise { return { - configStep: 0, + configStep: { current: 0, total: 0 }, showAdvanced: false, }; } diff --git a/unraid-ui/src/forms/SteppedLayout.vue b/unraid-ui/src/forms/SteppedLayout.vue index 0db6c3a1f..7624fb8e6 100644 --- a/unraid-ui/src/forms/SteppedLayout.vue +++ b/unraid-ui/src/forms/SteppedLayout.vue @@ -16,7 +16,7 @@ import { type UISchemaElement, } from '@jsonforms/core'; import { DispatchRenderer, useJsonFormsLayout, type RendererProps } from '@jsonforms/vue'; -import { computed, inject, ref, type Ref } from 'vue'; +import { computed, inject, nextTick, onMounted, ref, type Ref } from 'vue'; // Define props based on RendererProps const props = defineProps>(); @@ -48,15 +48,30 @@ const numSteps = computed(() => stepsConfig.value.length); // --- Current Step Logic --- Use injected core.data const currentStep = computed(() => { const stepData = core!.data?.configStep; - // Handle both the new object format and the old number format + + // Return current step if properly initialized if (typeof stepData === 'object' && stepData !== null && typeof stepData.current === 'number') { - // Ensure step is within bounds return Math.max(0, Math.min(stepData.current, numSteps.value - 1)); } - // Fallback for initial state or old number format - const numericStep = typeof stepData === 'number' ? stepData : 0; - return Math.max(0, Math.min(numericStep, numSteps.value - 1)); + + // Return 0 as default if not initialized yet + return 0; }); + +// Initialize configStep on mount +onMounted(async () => { + // Wait for next tick to ensure form data is available + await nextTick(); + + const stepData = core!.data?.configStep; + + // Only initialize if configStep doesn't exist or is in wrong format + if (!stepData || typeof stepData !== 'object' || typeof stepData.current !== 'number') { + const initialStep = { current: 0, total: numSteps.value }; + dispatch(Actions.update('configStep', () => initialStep)); + } +}); + const isLastStep = computed(() => numSteps.value > 0 && currentStep.value === numSteps.value - 1); // --- Step Update Logic --- @@ -81,6 +96,7 @@ const currentStepElements = computed(() => { element.options.step === currentStep.value ); }); + return filtered; }); @@ -142,9 +158,6 @@ const getStepState = (stepIndex: number): StepState => { /> - - -
import { computed, provide, ref, watch } from 'vue'; import { useMutation, useQuery } from '@vue/apollo-composable'; -import type { Ref } from 'vue'; import { Button, JsonForms } from '@unraid/ui'; -import { CREATE_BACKUP_JOB_CONFIG_MUTATION, BACKUP_JOB_CONFIG_FORM_QUERY } from './backup-jobs.query'; -import type { CreateBackupJobConfigInput, InputMaybe, PreprocessConfigInput, BackupMode } from '~/composables/gql/graphql'; +import type { CreateBackupJobConfigInput } from '~/composables/gql/graphql'; +import type { Ref } from 'vue'; + +import { BACKUP_JOB_CONFIG_FORM_QUERY, CREATE_BACKUP_JOB_CONFIG_MUTATION } from './backup-jobs.query'; // Define emit events const emit = defineEmits<{ - complete: [] - cancel: [] -}>() + complete: []; + cancel: []; +}>(); // Define types for form state interface ConfigStep { @@ -30,7 +31,8 @@ const { refetch: updateFormSchema, } = useQuery(BACKUP_JOB_CONFIG_FORM_QUERY, { input: { - showAdvanced: typeof formState.value?.showAdvanced === 'boolean' ? formState.value.showAdvanced : false, + showAdvanced: + typeof formState.value?.showAdvanced === 'boolean' ? formState.value.showAdvanced : false, }, }); @@ -39,21 +41,31 @@ let refetchTimeout: NodeJS.Timeout | null = null; watch( formState, async (newValue, oldValue) => { - const newStepCurrent = typeof (newValue?.configStep) === 'object' ? (newValue.configStep as ConfigStep).current : (newValue?.configStep as number); - const oldStepCurrent = typeof (oldValue?.configStep) === 'object' ? (oldValue.configStep as ConfigStep).current : (oldValue?.configStep as number); + const newStepCurrent = (newValue?.configStep as ConfigStep)?.current ?? 0; + const oldStepCurrent = (oldValue?.configStep as ConfigStep)?.current ?? 0; const newShowAdvanced = typeof newValue?.showAdvanced === 'boolean' ? newValue.showAdvanced : false; const oldShowAdvanced = typeof oldValue?.showAdvanced === 'boolean' ? oldValue.showAdvanced : false; const shouldRefetch = newShowAdvanced !== oldShowAdvanced || newStepCurrent !== oldStepCurrent; + if (shouldRefetch) { if (newShowAdvanced !== oldShowAdvanced) { console.log('[BackupJobConfigForm] showAdvanced changed:', newShowAdvanced); } if (newStepCurrent !== oldStepCurrent) { - console.log('[BackupJobConfigForm] configStep.current changed:', newStepCurrent, 'from:', oldStepCurrent, 'Refetching schema.'); + console.log( + '[BackupJobConfigForm] configStep.current changed:', + newStepCurrent, + 'from:', + oldStepCurrent, + 'Refetching schema.' + ); } + + // Debounce refetch to prevent multiple rapid calls if (refetchTimeout) { clearTimeout(refetchTimeout); } + refetchTimeout = setTimeout(async () => { await updateFormSchema({ input: { @@ -80,22 +92,9 @@ const { // Handle form submission const submitForm = async () => { try { - const value = formState.value as Record; - console.log('value', value); - console.log('[BackupJobConfigForm] submitForm', value); - const input: CreateBackupJobConfigInput = { - name: value?.name as string, - destinationPath: value?.destinationPath as string, - schedule: (value?.schedule as string) || '', - enabled: value?.enabled as boolean, - remoteName: value?.remoteName as string, - sourcePath: (value?.sourcePath as string) || '', - rcloneOptions: value?.rcloneOptions as Record, - preprocessConfig: value?.preprocessConfig as InputMaybe | undefined, - backupMode: (value?.backupMode as BackupMode) || 'RAW' as BackupMode, - }; + const { configStep, ...input } = formState.value; await createBackupJobConfig({ - input, + input: input as CreateBackupJobConfigInput, }); } catch (error) { console.error('Error creating backup job config:', error); @@ -116,14 +115,30 @@ onCreateDone(async ({ data }) => { const parsedOriginalErrorMessage = computed(() => { const originalError = createError.value?.graphQLErrors?.[0]?.extensions?.originalError; - if (originalError && typeof originalError === 'object' && originalError !== null && 'message' in originalError) { + if ( + originalError && + typeof originalError === 'object' && + originalError !== null && + 'message' in originalError + ) { return (originalError as { message: string | string[] }).message; } return undefined; }); +let changeTimeout: NodeJS.Timeout | null = null; const onChange = ({ data }: { data: unknown }) => { - console.log('[BackupJobConfigForm] onChange', data); + // Clear any pending timeout + if (changeTimeout) { + clearTimeout(changeTimeout); + } + + // Debounce logging to reduce console spam + changeTimeout = setTimeout(() => { + console.log('[BackupJobConfigForm] onChange', data); + changeTimeout = null; + }, 300); + formState.value = data as Record; }; @@ -132,8 +147,8 @@ const uiSchema = computed(() => formResult.value?.backupJobConfigForm?.uiSchema) // Handle both number and object formats of configStep const getCurrentStep = computed(() => { - const step = formState.value?.configStep; - return typeof step === 'object' ? (step as ConfigStep).current : step as number; + const step = formState.value?.configStep as ConfigStep; + return step?.current ?? 0; }); // Get total steps from UI schema @@ -157,21 +172,33 @@ provide('isSubmitting', isCreating); - \ No newline at end of file + diff --git a/web/components/RClone/RCloneConfig.vue b/web/components/RClone/RCloneConfig.vue index 5a5794c49..fd8d74d18 100644 --- a/web/components/RClone/RCloneConfig.vue +++ b/web/components/RClone/RCloneConfig.vue @@ -30,7 +30,7 @@ interface ConfigStep { // Form state const formState = ref(props.initialState || { - configStep: 0 as number | ConfigStep, + configStep: { current: 0, total: 0 }, showAdvanced: false, name: '', type: '', @@ -56,13 +56,8 @@ watch( formState, async (newValue, oldValue) => { // Get current step as number for comparison - const newStep = typeof newValue.configStep === 'object' - ? (newValue.configStep as ConfigStep).current - : newValue.configStep as number; - - const oldStep = typeof oldValue.configStep === 'object' - ? (oldValue.configStep as ConfigStep).current - : oldValue.configStep as number; + const newStep = (newValue.configStep as ConfigStep)?.current ?? 0; + const oldStep = (oldValue.configStep as ConfigStep)?.current ?? 0; // Check if we need to refetch const shouldRefetch = @@ -138,7 +133,7 @@ onCreateDone(async ({ data }) => { // Reset form and emit complete event formState.value = { - configStep: 0, + configStep: { current: 0, total: 0 }, showAdvanced: false, name: '', type: '', @@ -179,8 +174,8 @@ const uiSchema = computed(() => formResult.value?.rclone?.configForm?.uiSchema); // Handle both number and object formats of configStep const getCurrentStep = computed(() => { - const step = formState.value.configStep; - return typeof step === 'object' ? (step as ConfigStep).current : step as number; + const step = formState.value.configStep as ConfigStep; + return step?.current ?? 0; }); // Get total steps from UI schema diff --git a/web/components/RClone/RCloneOverview.vue b/web/components/RClone/RCloneOverview.vue index e936adadd..78903da2d 100644 --- a/web/components/RClone/RCloneOverview.vue +++ b/web/components/RClone/RCloneOverview.vue @@ -10,7 +10,7 @@ import RCloneConfig from './RCloneConfig.vue'; import RemoteItem from './RemoteItem.vue'; interface FormState { - configStep: number; + configStep: { current: number; total: number }; showAdvanced: boolean; name: string; type: string; @@ -63,7 +63,7 @@ const openCryptModal = (remote: { name: string, type: string }) => { const entropy = Math.random().toString(36).substring(2, 8); selectedRemote.value = remote; initialFormState.value = { - configStep: 0, + configStep: { current: 0, total: 0 }, showAdvanced: false, name: `${remote.name}-crypt-${entropy}`, type: 'crypt',