diff --git a/code/UI/src/app/core/services/configuration.service.ts b/code/UI/src/app/core/services/configuration.service.ts new file mode 100644 index 00000000..c1d44091 --- /dev/null +++ b/code/UI/src/app/core/services/configuration.service.ts @@ -0,0 +1,208 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable, inject } from '@angular/core'; +import { Observable, catchError, map, throwError } from 'rxjs'; +import { environment } from '../../../environments/environment'; +import { JobSchedule, QueueCleanerConfig, ScheduleUnit } from '../../shared/models/queue-cleaner-config.model'; + +@Injectable({ + providedIn: 'root' +}) +export class ConfigurationService { + private readonly apiUrl = environment.apiUrl; + private readonly http = inject(HttpClient); + + /** + * Get queue cleaner configuration + */ + getQueueCleanerConfig(): Observable { + return this.http.get(`${this.apiUrl}/api/configuration/queue_cleaner`) + .pipe( + map(response => this.transformQueueCleanerResponse(response)), + catchError(error => { + console.error('Error fetching queue cleaner config:', error); + return throwError(() => new Error('Failed to load queue cleaner configuration')); + }) + ); + } + + /** + * Update queue cleaner configuration + */ + updateQueueCleanerConfig(config: QueueCleanerConfig): Observable { + // Create a copy to avoid modifying the original + const configToSend = this.prepareQueueCleanerConfigForSending({ ...config }); + + return this.http.put(`${this.apiUrl}/api/configuration/queue_cleaner`, configToSend) + .pipe( + catchError(error => { + console.error('Error updating queue cleaner config:', error); + return throwError(() => new Error(error.error?.error || 'Failed to update queue cleaner configuration')); + }) + ); + } + + /** + * Transform the API response to our frontend model + * Convert property names from PascalCase to camelCase + */ + private transformQueueCleanerResponse(response: any): QueueCleanerConfig { + const config: QueueCleanerConfig = { + enabled: response.Enabled, + cronExpression: response.CronExpression, + runSequentially: response.RunSequentially, + ignoredDownloadsPath: response.IgnoredDownloadsPath || '', + + // Failed Import settings + failedImportMaxStrikes: response.FailedImportMaxStrikes, + failedImportIgnorePrivate: response.FailedImportIgnorePrivate, + failedImportDeletePrivate: response.FailedImportDeletePrivate, + failedImportIgnorePatterns: response.FailedImportIgnorePatterns || [], + + // Stalled settings + stalledMaxStrikes: response.StalledMaxStrikes, + stalledResetStrikesOnProgress: response.StalledResetStrikesOnProgress, + stalledIgnorePrivate: response.StalledIgnorePrivate, + stalledDeletePrivate: response.StalledDeletePrivate, + + // Downloading Metadata settings + downloadingMetadataMaxStrikes: response.DownloadingMetadataMaxStrikes, + + // Slow Download settings + slowMaxStrikes: response.SlowMaxStrikes, + slowResetStrikesOnProgress: response.SlowResetStrikesOnProgress, + slowIgnorePrivate: response.SlowIgnorePrivate, + slowDeletePrivate: response.SlowDeletePrivate, + slowMinSpeed: response.SlowMinSpeed || '', + slowMaxTime: response.SlowMaxTime, + slowIgnoreAboveSize: response.SlowIgnoreAboveSize || '', + }; + + // Attempt to extract job schedule from cron expression + // This is just UI sugar, not sent back to API + config.jobSchedule = this.tryExtractJobScheduleFromCron(config.cronExpression); + + return config; + } + + /** + * Prepare configuration object for sending to API + * Convert property names from camelCase to PascalCase + */ + private prepareQueueCleanerConfigForSending(config: QueueCleanerConfig): any { + // If we have a job schedule, update the cron expression + if (config.jobSchedule) { + config.cronExpression = this.convertJobScheduleToCron(config.jobSchedule); + } + + // Remove UI-only properties + const { jobSchedule, ...rest } = config; + + // Convert to PascalCase for backend + return { + Enabled: rest.enabled, + CronExpression: rest.cronExpression, + RunSequentially: rest.runSequentially, + IgnoredDownloadsPath: rest.ignoredDownloadsPath, + + // Failed Import settings + FailedImportMaxStrikes: rest.failedImportMaxStrikes, + FailedImportIgnorePrivate: rest.failedImportIgnorePrivate, + FailedImportDeletePrivate: rest.failedImportDeletePrivate, + FailedImportIgnorePatterns: rest.failedImportIgnorePatterns, + + // Stalled settings + StalledMaxStrikes: rest.stalledMaxStrikes, + StalledResetStrikesOnProgress: rest.stalledResetStrikesOnProgress, + StalledIgnorePrivate: rest.stalledIgnorePrivate, + StalledDeletePrivate: rest.stalledDeletePrivate, + + // Downloading Metadata settings + DownloadingMetadataMaxStrikes: rest.downloadingMetadataMaxStrikes, + + // Slow Download settings + SlowMaxStrikes: rest.slowMaxStrikes, + SlowResetStrikesOnProgress: rest.slowResetStrikesOnProgress, + SlowIgnorePrivate: rest.slowIgnorePrivate, + SlowDeletePrivate: rest.slowDeletePrivate, + SlowMinSpeed: rest.slowMinSpeed, + SlowMaxTime: rest.slowMaxTime, + SlowIgnoreAboveSize: rest.slowIgnoreAboveSize, + }; + } + + /** + * Try to extract a JobSchedule from a cron expression + * Only handles the simple cases we're generating + */ + private tryExtractJobScheduleFromCron(cronExpression: string): JobSchedule | undefined { + // Patterns we support: + // Seconds: */n * * ? * * * + // Minutes: 0 */n * ? * * * + // Hours: 0 0 */n ? * * * + try { + const parts = cronExpression.split(' '); + + if (parts.length !== 7) return undefined; + + // Every n seconds + if (parts[0].startsWith('*/') && parts[1] === '*') { + const seconds = parseInt(parts[0].substring(2)); + if (!isNaN(seconds) && seconds > 0 && seconds < 60) { + return { every: seconds, type: ScheduleUnit.Seconds }; + } + } + + // Every n minutes + if (parts[0] === '0' && parts[1].startsWith('*/')) { + const minutes = parseInt(parts[1].substring(2)); + if (!isNaN(minutes) && minutes > 0 && minutes < 60) { + return { every: minutes, type: ScheduleUnit.Minutes }; + } + } + + // Every n hours + if (parts[0] === '0' && parts[1] === '0' && parts[2].startsWith('*/')) { + const hours = parseInt(parts[2].substring(2)); + if (!isNaN(hours) && hours > 0 && hours < 24) { + return { every: hours, type: ScheduleUnit.Hours }; + } + } + } catch (e) { + console.warn('Could not parse cron expression:', cronExpression); + } + + return undefined; + } + + /** + * Convert a JobSchedule to a cron expression + */ + private convertJobScheduleToCron(schedule: JobSchedule): string { + if (!schedule || schedule.every <= 0) { + return '0 0/5 * * * ?'; // Default: every 5 minutes + } + + switch (schedule.type) { + case ScheduleUnit.Seconds: + if (schedule.every < 60) { + return `*/${schedule.every} * * ? * * *`; + } + break; + + case ScheduleUnit.Minutes: + if (schedule.every < 60) { + return `0 */${schedule.every} * ? * * *`; + } + break; + + case ScheduleUnit.Hours: + if (schedule.every < 24) { + return `0 0 */${schedule.every} ? * * *`; + } + break; + } + + // Fallback to default + return '0 0/5 * * * ?'; + } +} diff --git a/code/UI/src/app/settings/settings-page/settings-page.component.html b/code/UI/src/app/settings/settings-page/settings-page.component.html index 6653bd7e..48621e6b 100644 --- a/code/UI/src/app/settings/settings-page/settings-page.component.html +++ b/code/UI/src/app/settings/settings-page/settings-page.component.html @@ -1,148 +1,260 @@
-
+ + + +

Settings

-
- - -
+

Manage application settings and configurations

-
-
- -
-

API Connection

- -
- -
- - - - - The base URL of the Cleanuparr API service -
-
- -
- -
-
- - -
- Required for authenticated API requests -
-
- -
- -
- - Maximum time to wait for API responses -
+ +
+ +
+ +
+ +
+ + When enabled, the queue cleaner will run according to the schedule
- - - -
-
- -
-
- - - - - + +
+ +
+ Every + + + + + +
+ How often the queue cleaner should run +
+ +
+ +
+ + When enabled, jobs will run one after another instead of in parallel +
+
+ +
+ +
+ + Path to the file containing ignored downloads +
+
+ + + + + +
+ +
+ + + Number of strikes before action is taken (0 to disable, min 3 to enable)
-
- -
- -
- -
{{ fontSize }}px
+ +
+ +
+ + When enabled, private torrents will be ignored +
-
+ +
+ +
+ + When enabled, private torrents will be deleted +
+
+ +
+ +
+ + Patterns to ignore (e.g., *sample*) +
+
+ + + + +
+ +
+ + + Number of strikes before action is taken (0 to disable, min 3 to enable) +
+
+ +
+ +
+ + When enabled, strikes will be reset if download progress is made +
+
+ +
+ +
+ + When enabled, private torrents will be ignored +
+
+ +
+ +
+ + When enabled, private torrents will be deleted +
+
+
+ + + +
+ +
+ + + Number of strikes before action is taken (0 to disable, min 3 to enable) +
+
+
+ + + +
+ +
+ + + Number of strikes before action is taken (0 to disable, min 3 to enable) +
+
+ +
+ +
+ + When enabled, strikes will be reset if download progress is made +
+
+ +
+ +
+ + When enabled, private torrents will be ignored +
+
+ +
+ +
+ + When enabled, private torrents will be deleted +
+
+ +
+ +
+ + +
+
+ +
+ +
+ + + Maximum time allowed for slow downloads (in hours) +
+
+ +
+ +
+ + +
+
+
+ + + +
+ +
- -
- -
- -
-
- -
- - When enabled, application events will be logged -
-
- -
- -
- - -
- -
-
- - - -
- Minimum level of logs to capture -
-
- -
- -
- - Show desktop notifications for important logs -
-
-
-
- - -
-
- -
- - Automatically refresh logs at the specified interval -
-
- -
- -
- -
{{ refreshInterval }} seconds
-
-
- -
- -
- - Maximum number of log entries to display at once -
-
-
-
-
-
- -
- + +
diff --git a/code/UI/src/app/settings/settings-page/settings-page.component.ts b/code/UI/src/app/settings/settings-page/settings-page.component.ts index 7efdc94b..662ecf2f 100644 --- a/code/UI/src/app/settings/settings-page/settings-page.component.ts +++ b/code/UI/src/app/settings/settings-page/settings-page.component.ts @@ -1,20 +1,33 @@ -import { Component, OnInit } from '@angular/core'; +import { Component, OnInit, computed, inject, effect } from '@angular/core'; import { CommonModule } from '@angular/common'; -import { FormsModule } from '@angular/forms'; +import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms'; import { environment } from '../../../environments/environment'; // PrimeNG Components import { CardModule } from 'primeng/card'; -import { InputTextModule } from 'primeng/inputtext'; -import { InputSwitchModule } from 'primeng/inputswitch'; import { ButtonModule } from 'primeng/button'; -import { DropdownModule } from 'primeng/dropdown'; -import { SliderModule } from 'primeng/slider'; -import { RadioButtonModule } from 'primeng/radiobutton'; +import { InputTextModule } from 'primeng/inputtext'; import { InputNumberModule } from 'primeng/inputnumber'; -import { TagModule } from 'primeng/tag'; +import { DropdownModule } from 'primeng/dropdown'; +import { PasswordModule } from 'primeng/password'; import { ToastModule } from 'primeng/toast'; import { MessageService } from 'primeng/api'; +import { CheckboxModule } from 'primeng/checkbox'; +import { AccordionModule } from 'primeng/accordion'; +import { SelectButtonModule } from 'primeng/selectbutton'; +import { ChipsModule } from 'primeng/chips'; +import { TagModule } from 'primeng/tag'; +import { SliderModule } from 'primeng/slider'; +import { RadioButtonModule } from 'primeng/radiobutton'; +import { DividerModule } from 'primeng/divider'; +import { InputGroupModule } from 'primeng/inputgroup'; +import { InputGroupAddonModule } from 'primeng/inputgroupaddon'; + +// Custom Components and Services +import { ByteSizeInputComponent } from '../../shared/components/byte-size-input/byte-size-input.component'; +import { QueueCleanerConfigStore } from '../store/queue-cleaner-config.store'; +import { QueueCleanerConfig, ScheduleUnit } from '../../shared/models/queue-cleaner-config.model'; +import { toSignal } from '@angular/core/rxjs-interop'; // Define interfaces for our settings interface LogLevel { @@ -33,18 +46,25 @@ interface MaxLogOption { imports: [ CommonModule, FormsModule, + ReactiveFormsModule, CardModule, InputTextModule, - InputSwitchModule, + CheckboxModule, ButtonModule, + InputNumberModule, DropdownModule, + PasswordModule, + ToastModule, + TagModule, SliderModule, RadioButtonModule, - InputNumberModule, - TagModule, - ToastModule + AccordionModule, + DividerModule, + SelectButtonModule, + ChipsModule, + ByteSizeInputComponent ], - providers: [MessageService], + providers: [MessageService, QueueCleanerConfigStore], templateUrl: './settings-page.component.html', styleUrl: './settings-page.component.scss' }) @@ -84,15 +104,117 @@ export class SettingsPageComponent implements OnInit { { label: '500 entries', value: 500 }, { label: '1000 entries', value: 1000 } ]; + + // Schedule unit options for job schedules + scheduleUnitOptions = [ + { label: 'Seconds', value: ScheduleUnit.Seconds }, + { label: 'Minutes', value: ScheduleUnit.Minutes }, + { label: 'Hours', value: ScheduleUnit.Hours } + ]; // UI state showSaveNotification = false; + + // Queue Cleaner Configuration Form + queueCleanerForm: FormGroup; - constructor(private messageService: MessageService) {} + // Inject the necessary services + private formBuilder = inject(FormBuilder); + private messageService = inject(MessageService); + private queueCleanerStore = inject(QueueCleanerConfigStore); + + // Signals from the store + readonly queueCleanerConfig = this.queueCleanerStore.config; + readonly queueCleanerLoading = this.queueCleanerStore.loading; + readonly queueCleanerSaving = this.queueCleanerStore.saving; + readonly queueCleanerError = this.queueCleanerStore.error; + + constructor() { + // Initialize the queue cleaner form + this.queueCleanerForm = this.formBuilder.group({ + enabled: [false], + jobSchedule: this.formBuilder.group({ + every: [5, [Validators.required, Validators.min(1)]], + type: [ScheduleUnit.Minutes] + }), + runSequentially: [false], + ignoredDownloadsPath: [''], + + // Failed Import settings + failedImportMaxStrikes: [0, [Validators.min(0)]], + failedImportIgnorePrivate: [false], + failedImportDeletePrivate: [false], + failedImportIgnorePatterns: [[]], + + // Stalled settings + stalledMaxStrikes: [0, [Validators.min(0)]], + stalledResetStrikesOnProgress: [false], + stalledIgnorePrivate: [false], + stalledDeletePrivate: [false], + + // Downloading Metadata settings + downloadingMetadataMaxStrikes: [0, [Validators.min(0)]], + + // Slow Download settings + slowMaxStrikes: [0, [Validators.min(0)]], + slowResetStrikesOnProgress: [false], + slowIgnorePrivate: [false], + slowDeletePrivate: [false], + slowMinSpeed: [''], + slowMaxTime: [0, [Validators.min(0)]], + slowIgnoreAboveSize: [''] + }); + } ngOnInit() { // Load saved settings if available this.loadSettings(); + + // The QueueCleanerConfigStore automatically loads the configuration when initialized + // Create an effect to update the form when the configuration changes + effect(() => { + const config = this.queueCleanerConfig(); + if (config) { + // Update the form with the current configuration + this.queueCleanerForm.patchValue({ + enabled: config.enabled, + runSequentially: config.runSequentially, + ignoredDownloadsPath: config.ignoredDownloadsPath, + + // Failed Import settings + failedImportMaxStrikes: config.failedImportMaxStrikes, + failedImportIgnorePrivate: config.failedImportIgnorePrivate, + failedImportDeletePrivate: config.failedImportDeletePrivate, + failedImportIgnorePatterns: config.failedImportIgnorePatterns, + + // Stalled settings + stalledMaxStrikes: config.stalledMaxStrikes, + stalledResetStrikesOnProgress: config.stalledResetStrikesOnProgress, + stalledIgnorePrivate: config.stalledIgnorePrivate, + stalledDeletePrivate: config.stalledDeletePrivate, + + // Downloading Metadata settings + downloadingMetadataMaxStrikes: config.downloadingMetadataMaxStrikes, + + // Slow Download settings + slowMaxStrikes: config.slowMaxStrikes, + slowResetStrikesOnProgress: config.slowResetStrikesOnProgress, + slowIgnorePrivate: config.slowIgnorePrivate, + slowDeletePrivate: config.slowDeletePrivate, + slowMinSpeed: config.slowMinSpeed, + slowMaxTime: config.slowMaxTime, + slowIgnoreAboveSize: config.slowIgnoreAboveSize + }); + + // Update job schedule if it exists + if (config.jobSchedule) { + this.queueCleanerForm.get('jobSchedule')?.patchValue({ + every: config.jobSchedule.every, + type: config.jobSchedule.type + }); + } + } + }); } getSeverity(level: string): string { @@ -185,4 +307,134 @@ export class SettingsPageComponent implements OnInit { resetApiUrl(): void { this.apiUrl = environment.apiUrl; } + + /** + * Save the queue cleaner configuration + */ + saveQueueCleanerConfig(): void { + if (this.queueCleanerForm.invalid) { + // Mark all fields as touched to show validation errors + this.markFormGroupTouched(this.queueCleanerForm); + this.messageService.add({ + severity: 'error', + summary: 'Validation Error', + detail: 'Please correct the errors in the form before saving.', + life: 5000 + }); + return; + } + + // Get the form values + const formValues = this.queueCleanerForm.value; + + // Build the configuration object + const config: QueueCleanerConfig = { + enabled: formValues.enabled, + // The cronExpression will be generated from the jobSchedule when saving + cronExpression: '', + jobSchedule: formValues.jobSchedule, + runSequentially: formValues.runSequentially, + ignoredDownloadsPath: formValues.ignoredDownloadsPath || '', + + // Failed Import settings + failedImportMaxStrikes: formValues.failedImportMaxStrikes, + failedImportIgnorePrivate: formValues.failedImportIgnorePrivate, + failedImportDeletePrivate: formValues.failedImportDeletePrivate, + failedImportIgnorePatterns: formValues.failedImportIgnorePatterns || [], + + // Stalled settings + stalledMaxStrikes: formValues.stalledMaxStrikes, + stalledResetStrikesOnProgress: formValues.stalledResetStrikesOnProgress, + stalledIgnorePrivate: formValues.stalledIgnorePrivate, + stalledDeletePrivate: formValues.stalledDeletePrivate, + + // Downloading Metadata settings + downloadingMetadataMaxStrikes: formValues.downloadingMetadataMaxStrikes, + + // Slow Download settings + slowMaxStrikes: formValues.slowMaxStrikes, + slowResetStrikesOnProgress: formValues.slowResetStrikesOnProgress, + slowIgnorePrivate: formValues.slowIgnorePrivate, + slowDeletePrivate: formValues.slowDeletePrivate, + slowMinSpeed: formValues.slowMinSpeed || '', + slowMaxTime: formValues.slowMaxTime, + slowIgnoreAboveSize: formValues.slowIgnoreAboveSize || '' + }; + + // Save the configuration + this.queueCleanerStore.saveConfig(config); + } + + /** + * Reset the queue cleaner configuration form to default values + */ + resetQueueCleanerConfig(): void { + this.queueCleanerForm.reset({ + enabled: false, + jobSchedule: { + every: 5, + type: ScheduleUnit.Minutes + }, + runSequentially: false, + ignoredDownloadsPath: '', + + // Failed Import settings + failedImportMaxStrikes: 0, + failedImportIgnorePrivate: false, + failedImportDeletePrivate: false, + failedImportIgnorePatterns: [], + + // Stalled settings + stalledMaxStrikes: 0, + stalledResetStrikesOnProgress: false, + stalledIgnorePrivate: false, + stalledDeletePrivate: false, + + // Downloading Metadata settings + downloadingMetadataMaxStrikes: 0, + + // Slow Download settings + slowMaxStrikes: 0, + slowResetStrikesOnProgress: false, + slowIgnorePrivate: false, + slowDeletePrivate: false, + slowMinSpeed: '', + slowMaxTime: 0, + slowIgnoreAboveSize: '' + }); + } + + /** + * Mark all controls in a form group as touched + */ + private markFormGroupTouched(formGroup: FormGroup): void { + Object.values(formGroup.controls).forEach(control => { + control.markAsTouched(); + + if ((control as any).controls) { + this.markFormGroupTouched(control as FormGroup); + } + }); + } + + /** + * Check if a form control has an error after it's been touched + */ + hasError(controlName: string, errorName: string): boolean { + const control = this.queueCleanerForm.get(controlName); + return control ? control.touched && control.hasError(errorName) : false; + } + + /** + * Get nested form control errors + */ + hasNestedError(parentName: string, controlName: string, errorName: string): boolean { + const parentControl = this.queueCleanerForm.get(parentName); + if (!parentControl || !(parentControl instanceof FormGroup)) { + return false; + } + + const control = parentControl.get(controlName); + return control ? control.touched && control.hasError(errorName) : false; + } } diff --git a/code/UI/src/app/settings/store/queue-cleaner-config.store.ts b/code/UI/src/app/settings/store/queue-cleaner-config.store.ts new file mode 100644 index 00000000..cb412b27 --- /dev/null +++ b/code/UI/src/app/settings/store/queue-cleaner-config.store.ts @@ -0,0 +1,117 @@ +import { Injectable, inject } from '@angular/core'; +import { patchState, signalStore, withHooks, withMethods, withState } from '@ngrx/signals'; +import { rxMethod } from '@ngrx/signals/rxjs-interop'; +import { QueueCleanerConfig } from '../../shared/models/queue-cleaner-config.model'; +import { ConfigurationService } from '../../core/services/configuration.service'; +import { EMPTY, Observable, catchError, switchMap, tap } from 'rxjs'; +import { MessageService } from 'primeng/api'; + +export interface QueueCleanerConfigState { + config: QueueCleanerConfig | null; + loading: boolean; + saving: boolean; + error: string | null; +} + +const initialState: QueueCleanerConfigState = { + config: null, + loading: false, + saving: false, + error: null +}; + +@Injectable() +export class QueueCleanerConfigStore extends signalStore( + withState(initialState), + withMethods((store, configService = inject(ConfigurationService), messageService = inject(MessageService)) => ({ + + /** + * Load the queue cleaner configuration + */ + loadConfig: rxMethod( + pipe => pipe.pipe( + tap(() => patchState(store, { loading: true, error: null })), + switchMap(() => configService.getQueueCleanerConfig().pipe( + tap({ + next: (config) => patchState(store, { config, loading: false }), + error: (error) => { + patchState(store, { + loading: false, + error: error.message || 'Failed to load configuration' + }); + messageService.add({ + severity: 'error', + summary: 'Load Error', + detail: error.message || 'Failed to load queue cleaner configuration', + life: 5000 + }); + } + }), + catchError(() => EMPTY) + )) + ) + ), + + /** + * Save the queue cleaner configuration + */ + saveConfig: rxMethod( + (config$: Observable) => config$.pipe( + tap(() => patchState(store, { saving: true, error: null })), + switchMap(config => configService.updateQueueCleanerConfig(config).pipe( + tap({ + next: () => { + patchState(store, { + config, + saving: false + }); + messageService.add({ + severity: 'success', + summary: 'Success', + detail: 'Queue cleaner configuration saved successfully', + life: 3000 + }); + }, + error: (error) => { + patchState(store, { + saving: false, + error: error.message || 'Failed to save configuration' + }); + messageService.add({ + severity: 'error', + summary: 'Save Error', + detail: error.message || 'Failed to save queue cleaner configuration', + life: 5000 + }); + } + }), + catchError(() => EMPTY) + )) + ) + ), + + /** + * Update config in the store without saving to the backend + */ + updateConfigLocally(config: Partial) { + const currentConfig = store.config(); + if (currentConfig) { + patchState(store, { + config: { ...currentConfig, ...config } + }); + } + }, + + /** + * Reset any errors + */ + resetError() { + patchState(store, { error: null }); + } + })), + withHooks({ + onInit({ loadConfig }) { + loadConfig(); + } + }) +) {} diff --git a/code/UI/src/app/shared/components/byte-size-input/byte-size-input.component.html b/code/UI/src/app/shared/components/byte-size-input/byte-size-input.component.html new file mode 100644 index 00000000..bdc87103 --- /dev/null +++ b/code/UI/src/app/shared/components/byte-size-input/byte-size-input.component.html @@ -0,0 +1,25 @@ +
+
+ + + + + +
+ + {{ helpText }} +
diff --git a/code/UI/src/app/shared/components/byte-size-input/byte-size-input.component.scss b/code/UI/src/app/shared/components/byte-size-input/byte-size-input.component.scss new file mode 100644 index 00000000..9376366c --- /dev/null +++ b/code/UI/src/app/shared/components/byte-size-input/byte-size-input.component.scss @@ -0,0 +1,18 @@ +.byte-size-input { + width: 100%; + + .p-selectbutton { + height: 100%; + + .p-button { + min-width: 3rem; + } + } + + .form-helper-text { + color: var(--text-color-secondary); + font-size: 0.875rem; + margin-top: 0.25rem; + display: block; + } +} diff --git a/code/UI/src/app/shared/components/byte-size-input/byte-size-input.component.ts b/code/UI/src/app/shared/components/byte-size-input/byte-size-input.component.ts new file mode 100644 index 00000000..cd7dcc4b --- /dev/null +++ b/code/UI/src/app/shared/components/byte-size-input/byte-size-input.component.ts @@ -0,0 +1,111 @@ +import { Component, Input, forwardRef, signal } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ControlValueAccessor, FormsModule, NG_VALUE_ACCESSOR, ReactiveFormsModule } from '@angular/forms'; +import { InputNumberModule } from 'primeng/inputnumber'; +import { SelectButtonModule } from 'primeng/selectbutton'; + +type ByteSizeUnit = 'KB' | 'MB' | 'GB' | 'TB'; + +@Component({ + selector: 'app-byte-size-input', + standalone: true, + imports: [CommonModule, FormsModule, ReactiveFormsModule, InputNumberModule, SelectButtonModule], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ByteSizeInputComponent), + multi: true + } + ], + templateUrl: './byte-size-input.component.html', + styleUrl: './byte-size-input.component.scss' +}) +export class ByteSizeInputComponent implements ControlValueAccessor { + @Input() label: string = 'Size'; + @Input() min: number = 0; + @Input() disabled: boolean = false; + @Input() placeholder: string = 'Enter size'; + @Input() helpText: string = ''; + + // Value in the selected unit + value = signal(null); + + // The selected unit + unit = signal('MB'); + + // Available units + unitOptions = [ + { label: 'KB', value: 'KB' }, + { label: 'MB', value: 'MB' }, + { label: 'GB', value: 'GB' }, + { label: 'TB', value: 'TB' } + ]; + + // ControlValueAccessor interface methods + private onChange: (value: string) => void = () => {}; + private onTouched: () => void = () => {}; + + /** + * Parse the string value in format '100MB', '1.5GB', etc. + */ + writeValue(value: string): void { + if (!value) { + this.value.set(null); + return; + } + + try { + // Parse values like "100MB", "1.5GB", etc. + const regex = /^([\d.]+)([KMGT]B)$/i; + const match = value.match(regex); + + if (match) { + const numValue = parseFloat(match[1]); + const unit = match[2].toUpperCase() as ByteSizeUnit; + + this.value.set(numValue); + this.unit.set(unit); + } else { + this.value.set(null); + } + } catch (e) { + console.error('Error parsing byte size value:', value, e); + this.value.set(null); + } + } + + registerOnChange(fn: any): void { + this.onChange = fn; + } + + registerOnTouched(fn: any): void { + this.onTouched = fn; + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + } + + /** + * Update the value and notify the form control + */ + updateValue(): void { + this.onTouched(); + + if (this.value() === null) { + this.onChange(''); + return; + } + + // Format as "100MB", "1.5GB", etc. + const formattedValue = `${this.value()}${this.unit()}`; + this.onChange(formattedValue); + } + + /** + * Update the unit and notify the form control + */ + updateUnit(): void { + this.updateValue(); + } +} diff --git a/code/UI/src/app/shared/models/queue-cleaner-config.model.ts b/code/UI/src/app/shared/models/queue-cleaner-config.model.ts new file mode 100644 index 00000000..114fb250 --- /dev/null +++ b/code/UI/src/app/shared/models/queue-cleaner-config.model.ts @@ -0,0 +1,42 @@ +export enum ScheduleUnit { + Seconds = 'Seconds', + Minutes = 'Minutes', + Hours = 'Hours' +} + +export interface JobSchedule { + every: number; + type: ScheduleUnit; +} + +export interface QueueCleanerConfig { + enabled: boolean; + cronExpression: string; + jobSchedule?: JobSchedule; // UI-only field, not sent to API + runSequentially: boolean; + ignoredDownloadsPath: string; + + // Failed Import settings + failedImportMaxStrikes: number; + failedImportIgnorePrivate: boolean; + failedImportDeletePrivate: boolean; + failedImportIgnorePatterns: string[]; + + // Stalled settings + stalledMaxStrikes: number; + stalledResetStrikesOnProgress: boolean; + stalledIgnorePrivate: boolean; + stalledDeletePrivate: boolean; + + // Downloading Metadata settings + downloadingMetadataMaxStrikes: number; + + // Slow Download settings + slowMaxStrikes: number; + slowResetStrikesOnProgress: boolean; + slowIgnorePrivate: boolean; + slowDeletePrivate: boolean; + slowMinSpeed: string; + slowMaxTime: number; + slowIgnoreAboveSize: string; +}