added queue cleaner settings

This commit is contained in:
Flaminel
2025-05-30 18:36:38 +03:00
parent 97473b47fd
commit b289b2ee39
8 changed files with 1035 additions and 150 deletions

View File

@@ -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<QueueCleanerConfig> {
return this.http.get<QueueCleanerConfig>(`${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<any> {
// Create a copy to avoid modifying the original
const configToSend = this.prepareQueueCleanerConfigForSending({ ...config });
return this.http.put<any>(`${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 * * * ?';
}
}

View File

@@ -1,148 +1,260 @@
<div class="settings-container">
<div class="flex align-items-center justify-content-between mb-4">
<!-- Toast for notifications -->
<p-toast></p-toast>
<div class="settings-header">
<h1>Settings</h1>
<div>
<button pButton label="Save Changes" icon="pi pi-save" class="p-button-success mr-2" (click)="saveSettings()"></button>
<button pButton label="Reset to Defaults" icon="pi pi-refresh" class="p-button-secondary p-button-outlined" (click)="resetToDefaults()"></button>
</div>
<p>Manage application settings and configurations</p>
</div>
<div class="grid">
<div class="col-12 md:col-6">
<p-card header="General Settings" styleClass="settings-card mb-4">
<div class="settings-section">
<h3 class="settings-section-title">API Connection</h3>
<div class="field-row">
<label for="apiUrl">API URL</label>
<div class="field-input">
<span class="p-input-icon-left w-full">
<i class="pi pi-link"></i>
<input id="apiUrl" type="text" pInputText [(ngModel)]="apiUrl" class="w-full" placeholder="https://api.example.com" />
</span>
<small class="form-helper-text">The base URL of the Cleanuparr API service</small>
</div>
</div>
<div class="field-row">
<label for="apiKey">API Key</label>
<div class="field-input">
<div class="p-inputgroup">
<input id="apiKey" type="password" pInputText [(ngModel)]="apiKey" class="w-full" placeholder="Enter API key" />
<button type="button" pButton icon="pi pi-eye" class="p-button-outlined"></button>
</div>
<small class="form-helper-text">Required for authenticated API requests</small>
</div>
</div>
<div class="field-row">
<label for="apiTimeout">Timeout (seconds)</label>
<div class="field-input">
<p-inputNumber id="apiTimeout" [(ngModel)]="apiTimeout" [min]="5" [max]="120" [showButtons]="true" suffix=" sec" buttonLayout="horizontal" spinnerMode="horizontal"
inputStyleClass="text-right" [step]="5" decrementButtonClass="p-button-secondary" incrementButtonClass="p-button-secondary"></p-inputNumber>
<small class="form-helper-text">Maximum time to wait for API responses</small>
</div>
<!-- Queue Cleaner Settings Card -->
<div class="col-12 mb-4">
<p-card header="Queue Cleaner Configuration" styleClass="settings-card">
<form [formGroup]="queueCleanerForm" class="p-fluid">
<!-- Main Settings -->
<div class="field-row">
<label class="field-label">Enable Queue Cleaner</label>
<div class="field-input">
<p-checkbox formControlName="enabled" [binary]="true" inputId="qcEnabled"></p-checkbox>
<small class="form-helper-text">When enabled, the queue cleaner will run according to the schedule</small>
</div>
</div>
</p-card>
<p-card header="UI Preferences" styleClass="settings-card">
<div class="settings-section">
<div class="field-row align-items-center">
<label for="theme">Theme</label>
<div class="field-input">
<div class="flex gap-3 align-items-center">
<p-radioButton id="themeLight" name="theme" value="light" [(ngModel)]="theme" inputId="light"></p-radioButton>
<label for="light" class="mr-5">Light</label>
<p-radioButton id="themeDark" name="theme" value="dark" [(ngModel)]="theme" inputId="dark"></p-radioButton>
<label for="dark">Dark</label>
<div class="field-row" formGroupName="jobSchedule">
<label class="field-label">Run Schedule</label>
<div class="field-input schedule-input">
<span class="schedule-label">Every</span>
<p-inputNumber
formControlName="every"
[showButtons]="true"
[min]="1"
[max]="59"
buttonLayout="horizontal"
inputStyleClass="schedule-value"
[disabled]="!queueCleanerForm.get('enabled')?.value">
</p-inputNumber>
<p-selectButton
formControlName="type"
[options]="scheduleUnitOptions"
optionLabel="label"
optionValue="value"
[disabled]="!queueCleanerForm.get('enabled')?.value">
</p-selectButton>
</div>
<small class="form-helper-text">How often the queue cleaner should run</small>
</div>
<div class="field-row">
<label class="field-label">Run Sequentially</label>
<div class="field-input">
<p-checkbox formControlName="runSequentially" [binary]="true" inputId="qcRunSequentially" [disabled]="!queueCleanerForm.get('enabled')?.value"></p-checkbox>
<small class="form-helper-text">When enabled, jobs will run one after another instead of in parallel</small>
</div>
</div>
<div class="field-row">
<label class="field-label">Ignored Downloads Path</label>
<div class="field-input">
<input pInputText formControlName="ignoredDownloadsPath" [disabled]="!queueCleanerForm.get('enabled')?.value" />
<small class="form-helper-text">Path to the file containing ignored downloads</small>
</div>
</div>
<!-- Detailed Settings in Accordion -->
<p-accordion [multiple]="true" styleClass="mt-3">
<!-- Failed Import Settings -->
<p-accordionTab header="Failed Import Settings" [disabled]="!queueCleanerForm.get('enabled')?.value">
<div class="field-row">
<label class="field-label">Max Strikes</label>
<div class="field-input">
<p-inputNumber
formControlName="failedImportMaxStrikes"
[showButtons]="true"
[min]="0"
[max]="10"
buttonLayout="horizontal">
</p-inputNumber>
<small class="form-helper-text">Number of strikes before action is taken (0 to disable, min 3 to enable)</small>
</div>
</div>
</div>
<div class="field-row align-items-center">
<label for="fontSize">UI Font Size</label>
<div class="field-input">
<p-slider [(ngModel)]="fontSize" [min]="12" [max]="18" class="w-full mb-3"></p-slider>
<div class="text-center font-medium">{{ fontSize }}px</div>
<div class="field-row">
<label class="field-label">Ignore Private</label>
<div class="field-input">
<p-checkbox formControlName="failedImportIgnorePrivate" [binary]="true" [disabled]="queueCleanerForm.get('failedImportMaxStrikes')?.value < 3"></p-checkbox>
<small class="form-helper-text">When enabled, private torrents will be ignored</small>
</div>
</div>
</div>
<div class="field-row">
<label class="field-label">Delete Private</label>
<div class="field-input">
<p-checkbox formControlName="failedImportDeletePrivate" [binary]="true" [disabled]="queueCleanerForm.get('failedImportMaxStrikes')?.value < 3"></p-checkbox>
<small class="form-helper-text">When enabled, private torrents will be deleted</small>
</div>
</div>
<div class="field-row">
<label class="field-label">Ignore Patterns</label>
<div class="field-input">
<p-chips formControlName="failedImportIgnorePatterns" [disabled]="queueCleanerForm.get('failedImportMaxStrikes')?.value < 3" placeholder="Add pattern and press Enter"></p-chips>
<small class="form-helper-text">Patterns to ignore (e.g., *sample*)</small>
</div>
</div>
</p-accordionTab>
<!-- Stalled Settings -->
<p-accordionTab header="Stalled Download Settings" [disabled]="!queueCleanerForm.get('enabled')?.value">
<div class="field-row">
<label class="field-label">Max Strikes</label>
<div class="field-input">
<p-inputNumber
formControlName="stalledMaxStrikes"
[showButtons]="true"
[min]="0"
[max]="10"
buttonLayout="horizontal">
</p-inputNumber>
<small class="form-helper-text">Number of strikes before action is taken (0 to disable, min 3 to enable)</small>
</div>
</div>
<div class="field-row">
<label class="field-label">Reset Strikes On Progress</label>
<div class="field-input">
<p-checkbox formControlName="stalledResetStrikesOnProgress" [binary]="true" [disabled]="queueCleanerForm.get('stalledMaxStrikes')?.value < 3"></p-checkbox>
<small class="form-helper-text">When enabled, strikes will be reset if download progress is made</small>
</div>
</div>
<div class="field-row">
<label class="field-label">Ignore Private</label>
<div class="field-input">
<p-checkbox formControlName="stalledIgnorePrivate" [binary]="true" [disabled]="queueCleanerForm.get('stalledMaxStrikes')?.value < 3"></p-checkbox>
<small class="form-helper-text">When enabled, private torrents will be ignored</small>
</div>
</div>
<div class="field-row">
<label class="field-label">Delete Private</label>
<div class="field-input">
<p-checkbox formControlName="stalledDeletePrivate" [binary]="true" [disabled]="queueCleanerForm.get('stalledMaxStrikes')?.value < 3"></p-checkbox>
<small class="form-helper-text">When enabled, private torrents will be deleted</small>
</div>
</div>
</p-accordionTab>
<!-- Downloading Metadata Settings -->
<p-accordionTab header="Downloading Metadata Settings" [disabled]="!queueCleanerForm.get('enabled')?.value">
<div class="field-row">
<label class="field-label">Max Strikes</label>
<div class="field-input">
<p-inputNumber
formControlName="downloadingMetadataMaxStrikes"
[showButtons]="true"
[min]="0"
[max]="10"
buttonLayout="horizontal">
</p-inputNumber>
<small class="form-helper-text">Number of strikes before action is taken (0 to disable, min 3 to enable)</small>
</div>
</div>
</p-accordionTab>
<!-- Slow Download Settings -->
<p-accordionTab header="Slow Download Settings" [disabled]="!queueCleanerForm.get('enabled')?.value">
<div class="field-row">
<label class="field-label">Max Strikes</label>
<div class="field-input">
<p-inputNumber
formControlName="slowMaxStrikes"
[showButtons]="true"
[min]="0"
[max]="10"
buttonLayout="horizontal">
</p-inputNumber>
<small class="form-helper-text">Number of strikes before action is taken (0 to disable, min 3 to enable)</small>
</div>
</div>
<div class="field-row">
<label class="field-label">Reset Strikes On Progress</label>
<div class="field-input">
<p-checkbox formControlName="slowResetStrikesOnProgress" [binary]="true" [disabled]="queueCleanerForm.get('slowMaxStrikes')?.value < 3"></p-checkbox>
<small class="form-helper-text">When enabled, strikes will be reset if download progress is made</small>
</div>
</div>
<div class="field-row">
<label class="field-label">Ignore Private</label>
<div class="field-input">
<p-checkbox formControlName="slowIgnorePrivate" [binary]="true" [disabled]="queueCleanerForm.get('slowMaxStrikes')?.value < 3"></p-checkbox>
<small class="form-helper-text">When enabled, private torrents will be ignored</small>
</div>
</div>
<div class="field-row">
<label class="field-label">Delete Private</label>
<div class="field-input">
<p-checkbox formControlName="slowDeletePrivate" [binary]="true" [disabled]="queueCleanerForm.get('slowMaxStrikes')?.value < 3"></p-checkbox>
<small class="form-helper-text">When enabled, private torrents will be deleted</small>
</div>
</div>
<div class="field-row">
<label class="field-label">Minimum Speed</label>
<div class="field-input">
<app-byte-size-input
formControlName="slowMinSpeed"
[disabled]="queueCleanerForm.get('slowMaxStrikes')?.value < 3"
[min]="0"
placeholder="Enter minimum speed"
helpText="Minimum speed threshold for slow downloads (e.g., 100KB/s)">
</app-byte-size-input>
</div>
</div>
<div class="field-row">
<label class="field-label">Maximum Time (hours)</label>
<div class="field-input">
<p-inputNumber
formControlName="slowMaxTime"
[showButtons]="true"
[min]="0"
[max]="168"
buttonLayout="horizontal"
[disabled]="queueCleanerForm.get('slowMaxStrikes')?.value < 3">
</p-inputNumber>
<small class="form-helper-text">Maximum time allowed for slow downloads (in hours)</small>
</div>
</div>
<div class="field-row">
<label class="field-label">Ignore Above Size</label>
<div class="field-input">
<app-byte-size-input
formControlName="slowIgnoreAboveSize"
[disabled]="queueCleanerForm.get('slowMaxStrikes')?.value < 3"
[min]="0"
placeholder="Enter size threshold"
helpText="Size threshold above which slow downloads are ignored">
</app-byte-size-input>
</div>
</div>
</p-accordionTab>
</p-accordion>
<!-- Save & Reset buttons -->
<div class="form-actions mt-4">
<button pButton type="button" label="Save" icon="pi pi-save" class="p-button-success"
[disabled]="queueCleanerForm.pristine || queueCleanerSaving()"
[loading]="queueCleanerSaving()"
(click)="saveQueueCleanerConfig()"></button>
<button pButton type="button" label="Reset" icon="pi pi-refresh" class="p-button-secondary p-button-outlined ml-2"
(click)="resetQueueCleanerConfig()"></button>
</div>
</p-card>
</div>
<div class="col-12 md:col-6">
<p-card header="Logging Configuration" styleClass="settings-card mb-4">
<div class="settings-section">
<div class="field-row">
<label for="enableLogs">Enable Logging</label>
<div class="field-input">
<p-inputSwitch id="enableLogs" [(ngModel)]="enableLogs"></p-inputSwitch>
<small class="form-helper-text">When enabled, application events will be logged</small>
</div>
</div>
<div class="field-row" [class.field-disabled]="!enableLogs">
<label for="logLevel">Log Level</label>
<div class="field-input">
<p-dropdown id="logLevel" [options]="logLevels" [(ngModel)]="logLevel" optionLabel="label" [disabled]="!enableLogs"
placeholder="Select log level" [showClear]="false" styleClass="w-full">
<ng-template pTemplate="selectedItem">
<div class="flex align-items-center gap-2" *ngIf="logLevel">
<p-tag [severity]="getSeverity(logLevel.value)" [value]="logLevel.label"></p-tag>
</div>
</ng-template>
<ng-template let-level pTemplate="item">
<p-tag [severity]="getSeverity(level.value)" [value]="level.label"></p-tag>
</ng-template>
</p-dropdown>
<small class="form-helper-text">Minimum level of logs to capture</small>
</div>
</div>
<div class="field-row" [class.field-disabled]="!enableLogs">
<label for="enableNotifications">Show Notifications</label>
<div class="field-input">
<p-inputSwitch id="enableNotifications" [(ngModel)]="enableNotifications" [disabled]="!enableLogs"></p-inputSwitch>
<small class="form-helper-text">Show desktop notifications for important logs</small>
</div>
</div>
</div>
</p-card>
<p-card header="Log Viewer Settings" styleClass="settings-card">
<div class="settings-section">
<div class="field-row">
<label for="autoRefresh">Auto-refresh Logs</label>
<div class="field-input">
<p-inputSwitch id="autoRefresh" [(ngModel)]="autoRefresh"></p-inputSwitch>
<small class="form-helper-text">Automatically refresh logs at the specified interval</small>
</div>
</div>
<div class="field-row" [class.field-disabled]="!autoRefresh">
<label for="refreshInterval">Refresh Interval</label>
<div class="field-input">
<p-slider [(ngModel)]="refreshInterval" [min]="5" [max]="60" [disabled]="!autoRefresh" class="w-full mb-3"></p-slider>
<div class="text-center font-medium">{{ refreshInterval }} seconds</div>
</div>
</div>
<div class="field-row">
<label for="maxLogEntries">Max Log Entries</label>
<div class="field-input">
<p-dropdown id="maxLogEntries" [options]="maxLogOptions" [(ngModel)]="maxLogEntries"
optionLabel="label" optionValue="value" styleClass="w-full"></p-dropdown>
<small class="form-helper-text">Maximum number of log entries to display at once</small>
</div>
</div>
</div>
</p-card>
</div>
</div>
<div class="fixed bottom-0 right-0 p-3" *ngIf="showSaveNotification">
<p-toast position="bottom-right" key="settings"></p-toast>
</form>
</p-card>
</div>
</div>

View File

@@ -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;
}
}

View File

@@ -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<void>(
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<QueueCleanerConfig>(
(config$: Observable<QueueCleanerConfig>) => 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<QueueCleanerConfig>) {
const currentConfig = store.config();
if (currentConfig) {
patchState(store, {
config: { ...currentConfig, ...config }
});
}
},
/**
* Reset any errors
*/
resetError() {
patchState(store, { error: null });
}
})),
withHooks({
onInit({ loadConfig }) {
loadConfig();
}
})
) {}

View File

@@ -0,0 +1,25 @@
<div class="field-input byte-size-input">
<div class="p-inputgroup">
<p-inputNumber
[ngModel]="value()"
(ngModelChange)="value.set($event); updateValue()"
[min]="min"
[disabled]="disabled"
[placeholder]="placeholder"
[showButtons]="false"
[inputStyleClass]="'w-full'"
class="flex-auto">
</p-inputNumber>
<p-selectButton
[options]="unitOptions"
[ngModel]="unit()"
(ngModelChange)="unit.set($event); updateUnit()"
[disabled]="disabled"
optionLabel="label"
optionValue="value">
</p-selectButton>
</div>
<small class="form-helper-text" *ngIf="helpText">{{ helpText }}</small>
</div>

View File

@@ -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;
}
}

View File

@@ -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<number | null>(null);
// The selected unit
unit = signal<ByteSizeUnit>('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();
}
}

View File

@@ -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;
}