Add test button for arrs and download clients (#391)

This commit is contained in:
Flaminel
2025-12-20 17:06:03 +02:00
committed by GitHub
parent 58a72cef0f
commit 375094862c
37 changed files with 1114 additions and 165 deletions

View File

@@ -177,7 +177,7 @@ public class StatusController : ControllerBase
try
{
var sonarrClient = _arrClientFactory.GetClient(InstanceType.Sonarr);
await sonarrClient.TestConnectionAsync(instance);
await sonarrClient.HealthCheckAsync(instance);
sonarrStatus.Add(new
{
@@ -209,7 +209,7 @@ public class StatusController : ControllerBase
try
{
var radarrClient = _arrClientFactory.GetClient(InstanceType.Radarr);
await radarrClient.TestConnectionAsync(instance);
await radarrClient.HealthCheckAsync(instance);
radarrStatus.Add(new
{
@@ -241,7 +241,7 @@ public class StatusController : ControllerBase
try
{
var lidarrClient = _arrClientFactory.GetClient(InstanceType.Lidarr);
await lidarrClient.TestConnectionAsync(instance);
await lidarrClient.HealthCheckAsync(instance);
lidarrStatus.Add(new
{

View File

@@ -0,0 +1,24 @@
using System;
using System.ComponentModel.DataAnnotations;
using Cleanuparr.Persistence.Models.Configuration.Arr;
namespace Cleanuparr.Api.Features.Arr.Contracts.Requests;
public sealed record TestArrInstanceRequest
{
[Required]
public required string Url { get; init; }
[Required]
public required string ApiKey { get; init; }
public ArrInstance ToTestInstance() => new()
{
Enabled = true,
Name = "Test Instance",
Url = new Uri(Url),
ApiKey = ApiKey,
ArrConfigId = Guid.Empty,
};
}

View File

@@ -5,6 +5,7 @@ using System.Threading.Tasks;
using Cleanuparr.Api.Features.Arr.Contracts.Requests;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Arr.Dtos;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Mapster;
@@ -20,13 +21,16 @@ public sealed class ArrConfigController : ControllerBase
{
private readonly ILogger<ArrConfigController> _logger;
private readonly DataContext _dataContext;
private readonly IArrClientFactory _arrClientFactory;
public ArrConfigController(
ILogger<ArrConfigController> logger,
DataContext dataContext)
DataContext dataContext,
IArrClientFactory arrClientFactory)
{
_logger = logger;
_dataContext = dataContext;
_arrClientFactory = arrClientFactory;
}
[HttpGet("sonarr")]
@@ -124,6 +128,26 @@ public sealed class ArrConfigController : ControllerBase
public Task<IActionResult> DeleteWhisparrInstance(Guid id)
=> DeleteArrInstance(InstanceType.Whisparr, id);
[HttpPost("sonarr/instances/test")]
public Task<IActionResult> TestSonarrInstance([FromBody] TestArrInstanceRequest request)
=> TestArrInstance(InstanceType.Sonarr, request);
[HttpPost("radarr/instances/test")]
public Task<IActionResult> TestRadarrInstance([FromBody] TestArrInstanceRequest request)
=> TestArrInstance(InstanceType.Radarr, request);
[HttpPost("lidarr/instances/test")]
public Task<IActionResult> TestLidarrInstance([FromBody] TestArrInstanceRequest request)
=> TestArrInstance(InstanceType.Lidarr, request);
[HttpPost("readarr/instances/test")]
public Task<IActionResult> TestReadarrInstance([FromBody] TestArrInstanceRequest request)
=> TestArrInstance(InstanceType.Readarr, request);
[HttpPost("whisparr/instances/test")]
public Task<IActionResult> TestWhisparrInstance([FromBody] TestArrInstanceRequest request)
=> TestArrInstance(InstanceType.Whisparr, request);
private async Task<IActionResult> GetArrConfig(InstanceType type)
{
await DataContext.Lock.WaitAsync();
@@ -260,6 +284,23 @@ public sealed class ArrConfigController : ControllerBase
}
}
private async Task<IActionResult> TestArrInstance(InstanceType type, TestArrInstanceRequest request)
{
try
{
var testInstance = request.ToTestInstance();
var client = _arrClientFactory.GetClient(type);
await client.HealthCheckAsync(testInstance);
return Ok(new { Message = $"Connection to {type} instance successful" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to test {Type} instance connection", type);
return BadRequest(new { Message = $"Connection failed: {ex.Message}" });
}
}
private static string GetConfigActionName(InstanceType type) => type switch
{
InstanceType.Sonarr => nameof(GetSonarrConfig),

View File

@@ -0,0 +1,43 @@
using System;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Domain.Exceptions;
using Cleanuparr.Persistence.Models.Configuration;
namespace Cleanuparr.Api.Features.DownloadClient.Contracts.Requests;
public sealed record TestDownloadClientRequest
{
public DownloadClientTypeName TypeName { get; init; }
public DownloadClientType Type { get; init; }
public Uri? Host { get; init; }
public string? Username { get; init; }
public string? Password { get; init; }
public string? UrlBase { get; init; }
public void Validate()
{
if (Host is null)
{
throw new ValidationException("Host cannot be empty");
}
}
public DownloadClientConfig ToTestConfig() => new()
{
Id = Guid.NewGuid(),
Enabled = true,
Name = "Test Client",
TypeName = TypeName,
Type = Type,
Host = Host,
Username = Username,
Password = Password,
UrlBase = UrlBase,
};
}

View File

@@ -2,6 +2,7 @@ using System;
using System.Linq;
using Cleanuparr.Api.Features.DownloadClient.Contracts.Requests;
using Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Infrastructure.Http.DynamicHttpClientSystem;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration;
@@ -18,15 +19,18 @@ public sealed class DownloadClientController : ControllerBase
private readonly ILogger<DownloadClientController> _logger;
private readonly DataContext _dataContext;
private readonly IDynamicHttpClientFactory _dynamicHttpClientFactory;
private readonly IDownloadServiceFactory _downloadServiceFactory;
public DownloadClientController(
ILogger<DownloadClientController> logger,
DataContext dataContext,
IDynamicHttpClientFactory dynamicHttpClientFactory)
IDynamicHttpClientFactory dynamicHttpClientFactory,
IDownloadServiceFactory downloadServiceFactory)
{
_logger = logger;
_dataContext = dataContext;
_dynamicHttpClientFactory = dynamicHttpClientFactory;
_downloadServiceFactory = downloadServiceFactory;
}
[HttpGet("download_client")]
@@ -146,4 +150,33 @@ public sealed class DownloadClientController : ControllerBase
DataContext.Lock.Release();
}
}
[HttpPost("download_client/test")]
public async Task<IActionResult> TestDownloadClient([FromBody] TestDownloadClientRequest request)
{
try
{
request.Validate();
var testConfig = request.ToTestConfig();
using var downloadService = _downloadServiceFactory.GetDownloadService(testConfig);
var healthResult = await downloadService.HealthCheckAsync();
if (healthResult.IsHealthy)
{
return Ok(new
{
Message = $"Connection to {request.TypeName} successful",
ResponseTime = healthResult.ResponseTime.TotalMilliseconds
});
}
return BadRequest(new { Message = healthResult.ErrorMessage ?? "Connection failed" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to test {TypeName} client connection", request.TypeName);
return BadRequest(new { Message = $"Connection failed: {ex.Message}" });
}
}
}

View File

@@ -586,12 +586,12 @@ public sealed class NotificationProvidersController : ControllerBase
};
await _notificationService.SendTestNotificationAsync(providerDto);
return Ok(new { Message = "Test notification sent successfully", Success = true });
return Ok(new { Message = "Test notification sent successfully" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to test Notifiarr provider");
throw;
return BadRequest(new { Message = $"Test failed: {ex.Message}" });
}
}
@@ -627,12 +627,12 @@ public sealed class NotificationProvidersController : ControllerBase
};
await _notificationService.SendTestNotificationAsync(providerDto);
return Ok(new { Message = "Test notification sent successfully", Success = true });
return Ok(new { Message = "Test notification sent successfully" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to test Apprise provider");
throw;
return BadRequest(new { Message = $"Test failed: {ex.Message}" });
}
}
@@ -673,12 +673,12 @@ public sealed class NotificationProvidersController : ControllerBase
};
await _notificationService.SendTestNotificationAsync(providerDto);
return Ok(new { Message = "Test notification sent successfully", Success = true });
return Ok(new { Message = "Test notification sent successfully" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to test Ntfy provider");
throw;
return BadRequest(new { Message = $"Test failed: {ex.Message}" });
}
}
@@ -899,12 +899,12 @@ public sealed class NotificationProvidersController : ControllerBase
};
await _notificationService.SendTestNotificationAsync(providerDto);
return Ok(new { Message = "Test notification sent successfully", Success = true });
return Ok(new { Message = "Test notification sent successfully" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to test Pushover provider");
throw;
return BadRequest(new { Message = $"Test failed: {ex.Message}" });
}
}
}

View File

@@ -168,16 +168,12 @@ public abstract class ArrClient : IArrClient
return true;
}
/// <summary>
/// Tests the connection to an Arr instance
/// </summary>
/// <param name="arrInstance">The instance to test connection to</param>
/// <returns>Task that completes when the connection test is done</returns>
public virtual async Task TestConnectionAsync(ArrInstance arrInstance)
/// <inheritdoc/>
public async Task HealthCheckAsync(ArrInstance arrInstance)
{
UriBuilder uriBuilder = new(arrInstance.Url);
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/system/status";
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}{GetSystemStatusUrlPath()}";
using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri);
SetApiKey(request, arrInstance.ApiKey);
@@ -188,6 +184,8 @@ public abstract class ArrClient : IArrClient
_logger.LogDebug("Connection test successful for {url}", arrInstance.Url);
}
protected abstract string GetSystemStatusUrlPath();
protected abstract string GetQueueUrlPath();

View File

@@ -16,11 +16,11 @@ public interface IArrClient
Task SearchItemsAsync(ArrInstance arrInstance, HashSet<SearchItem>? items);
bool IsRecordValid(QueueRecord record);
/// <summary>
/// Tests the connection to an Arr instance
/// </summary>
/// <param name="arrInstance">The instance to test connection to</param>
/// <returns>Task that completes when the connection test is done</returns>
Task TestConnectionAsync(ArrInstance arrInstance);
Task HealthCheckAsync(ArrInstance arrInstance);
}

View File

@@ -22,6 +22,11 @@ public class LidarrClient : ArrClient, ILidarrClient
{
}
protected override string GetSystemStatusUrlPath()
{
return "/api/v1/system/status";
}
protected override string GetQueueUrlPath()
{
return "/api/v1/queue";

View File

@@ -21,6 +21,11 @@ public class RadarrClient : ArrClient, IRadarrClient
) : base(logger, httpClientFactory, striker, dryRunInterceptor)
{
}
protected override string GetSystemStatusUrlPath()
{
return "/api/v3/system/status";
}
protected override string GetQueueUrlPath()
{

View File

@@ -21,6 +21,11 @@ public class ReadarrClient : ArrClient, IReadarrClient
) : base(logger, httpClientFactory, striker, dryRunInterceptor)
{
}
protected override string GetSystemStatusUrlPath()
{
return "/api/v1/system/status";
}
protected override string GetQueueUrlPath()
{

View File

@@ -25,6 +25,11 @@ public class SonarrClient : ArrClient, ISonarrClient
{
}
protected override string GetSystemStatusUrlPath()
{
return "/api/v3/system/status";
}
protected override string GetQueueUrlPath()
{
return "/api/v3/queue";

View File

@@ -25,6 +25,11 @@ public class WhisparrClient : ArrClient, IWhisparrClient
{
}
protected override string GetSystemStatusUrlPath()
{
return "/api/v3/system/status";
}
protected override string GetQueueUrlPath()
{
return "/api/v3/queue";

View File

@@ -8,8 +8,8 @@ import { RadarrConfig } from "../../shared/models/radarr-config.model";
import { LidarrConfig } from "../../shared/models/lidarr-config.model";
import { ReadarrConfig } from "../../shared/models/readarr-config.model";
import { WhisparrConfig } from "../../shared/models/whisparr-config.model";
import { ClientConfig, DownloadClientConfig, CreateDownloadClientDto } from "../../shared/models/download-client-config.model";
import { ArrInstance, CreateArrInstanceDto } from "../../shared/models/arr-config.model";
import { ClientConfig, DownloadClientConfig, CreateDownloadClientDto, TestDownloadClientRequest, TestConnectionResult } from "../../shared/models/download-client-config.model";
import { ArrInstance, CreateArrInstanceDto, TestArrInstanceRequest } from "../../shared/models/arr-config.model";
import { GeneralConfig } from "../../shared/models/general-config.model";
import {
StallRule,
@@ -765,4 +765,84 @@ export class ConfigurationService {
})
);
}
// ===== CONNECTION TESTING =====
/**
* Test a Sonarr instance connection
*/
testSonarrInstance(request: TestArrInstanceRequest): Observable<TestConnectionResult> {
return this.http.post<TestConnectionResult>(this.ApplicationPathService.buildApiUrl('/configuration/sonarr/instances/test'), request).pipe(
catchError((error) => {
console.error("Error testing Sonarr instance:", error);
const errorMessage = ErrorHandlerUtil.extractErrorMessage(error);
return throwError(() => new Error(errorMessage));
})
);
}
/**
* Test a Radarr instance connection
*/
testRadarrInstance(request: TestArrInstanceRequest): Observable<TestConnectionResult> {
return this.http.post<TestConnectionResult>(this.ApplicationPathService.buildApiUrl('/configuration/radarr/instances/test'), request).pipe(
catchError((error) => {
console.error("Error testing Radarr instance:", error);
const errorMessage = ErrorHandlerUtil.extractErrorMessage(error);
return throwError(() => new Error(errorMessage));
})
);
}
/**
* Test a Lidarr instance connection
*/
testLidarrInstance(request: TestArrInstanceRequest): Observable<TestConnectionResult> {
return this.http.post<TestConnectionResult>(this.ApplicationPathService.buildApiUrl('/configuration/lidarr/instances/test'), request).pipe(
catchError((error) => {
console.error("Error testing Lidarr instance:", error);
const errorMessage = ErrorHandlerUtil.extractErrorMessage(error);
return throwError(() => new Error(errorMessage));
})
);
}
/**
* Test a Readarr instance connection
*/
testReadarrInstance(request: TestArrInstanceRequest): Observable<TestConnectionResult> {
return this.http.post<TestConnectionResult>(this.ApplicationPathService.buildApiUrl('/configuration/readarr/instances/test'), request).pipe(
catchError((error) => {
console.error("Error testing Readarr instance:", error);
const errorMessage = ErrorHandlerUtil.extractErrorMessage(error);
return throwError(() => new Error(errorMessage));
})
);
}
/**
* Test a Whisparr instance connection
*/
testWhisparrInstance(request: TestArrInstanceRequest): Observable<TestConnectionResult> {
return this.http.post<TestConnectionResult>(this.ApplicationPathService.buildApiUrl('/configuration/whisparr/instances/test'), request).pipe(
catchError((error) => {
console.error("Error testing Whisparr instance:", error);
const errorMessage = ErrorHandlerUtil.extractErrorMessage(error);
return throwError(() => new Error(errorMessage));
})
);
}
/**
* Test a download client connection
*/
testDownloadClient(request: TestDownloadClientRequest): Observable<TestConnectionResult> {
return this.http.post<TestConnectionResult>(this.ApplicationPathService.buildApiUrl('/configuration/download_client/test'), request).pipe(
catchError((error) => {
console.error("Error testing download client:", error);
const errorMessage = ErrorHandlerUtil.extractErrorMessage(error);
return throwError(() => new Error(errorMessage));
})
);
}
}

View File

@@ -1,7 +1,7 @@
import { Injectable, inject } from '@angular/core';
import { patchState, signalStore, withHooks, withMethods, withState } from '@ngrx/signals';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { ClientConfig, DownloadClientConfig, CreateDownloadClientDto } from '../../shared/models/download-client-config.model';
import { ClientConfig, DownloadClientConfig, CreateDownloadClientDto, TestDownloadClientRequest, TestConnectionResult } from '../../shared/models/download-client-config.model';
import { ConfigurationService } from '../../core/services/configuration.service';
import { EMPTY, Observable, catchError, switchMap, tap, forkJoin, of } from 'rxjs';
@@ -11,6 +11,10 @@ export interface DownloadClientConfigState {
saving: boolean;
error: string | null;
pendingOperations: number;
testing: boolean;
testingClientId: string | null;
testError: string | null;
testResult: TestConnectionResult | null;
}
const initialState: DownloadClientConfigState = {
@@ -18,7 +22,11 @@ const initialState: DownloadClientConfigState = {
loading: false,
saving: false,
error: null,
pendingOperations: 0
pendingOperations: 0,
testing: false,
testingClientId: null,
testError: null,
testResult: null
};
@Injectable()
@@ -91,7 +99,41 @@ export class DownloadClientConfigStore extends signalStore(
resetError() {
patchState(store, { error: null });
},
/**
* Reset test state
*/
resetTestState() {
patchState(store, { testing: false, testingClientId: null, testError: null, testResult: null });
},
/**
* Test a download client connection
*/
testClient: rxMethod<{ request: TestDownloadClientRequest; clientId?: string }>(
(params$: Observable<{ request: TestDownloadClientRequest; clientId?: string }>) => params$.pipe(
tap(({ clientId }) => patchState(store, { testing: true, testingClientId: clientId || null, testError: null, testResult: null })),
switchMap(({ request }) => configService.testDownloadClient(request).pipe(
tap({
next: (result) => {
patchState(store, {
testing: false,
testResult: result,
testError: null
});
},
error: (error) => {
patchState(store, {
testing: false,
testError: error.message || 'Connection test failed'
});
}
}),
catchError(() => EMPTY)
))
)
),
/**
* Batch create multiple clients
*/

View File

@@ -68,20 +68,31 @@
</div>
</div>
<div class="item-actions">
<button
pButton
type="button"
icon="pi pi-pencil"
<button
pButton
type="button"
icon="pi pi-play"
class="p-button-rounded p-button-text p-button-sm mr-1"
pTooltip="Test connection"
tooltipPosition="left"
[disabled]="testing() || downloadClientSaving()"
[loading]="testingClientId() === client.id"
(click)="testClientFromList(client)"
></button>
<button
pButton
type="button"
icon="pi pi-pencil"
class="p-button-rounded p-button-text p-button-sm mr-1"
pTooltip="Edit client"
tooltipPosition="left"
[disabled]="downloadClientSaving()"
(click)="openEditClientModal(client)"
></button>
<button
pButton
type="button"
icon="pi pi-trash"
<button
pButton
type="button"
icon="pi pi-trash"
class="p-button-rounded p-button-text p-button-danger p-button-sm"
pTooltip="Delete client"
tooltipPosition="left"
@@ -248,17 +259,27 @@
<ng-template pTemplate="footer">
<div class="modal-footer">
<button
pButton
type="button"
label="Cancel"
<button
pButton
type="button"
label="Cancel"
class="p-button-text"
(click)="closeClientModal()"
></button>
<button
pButton
type="button"
label="Save"
<button
pButton
type="button"
label="Test"
icon="pi pi-play"
class="p-button-outlined ml-2"
[disabled]="clientForm.invalid || testing()"
[loading]="testing()"
(click)="testClientFromModal()"
></button>
<button
pButton
type="button"
label="Save"
icon="pi pi-save"
class="p-button-primary ml-2"
[disabled]="clientForm.invalid || downloadClientSaving()"

View File

@@ -1,10 +1,10 @@
import { Component, EventEmitter, OnDestroy, Output, effect, inject } from "@angular/core";
import { CommonModule } from "@angular/common";
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators, AbstractControl, ValidationErrors } from "@angular/forms";
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms";
import { Subject, takeUntil } from "rxjs";
import { DownloadClientConfigStore } from "./download-client-config.store";
import { CanComponentDeactivate } from "../../core/guards";
import { ClientConfig, DownloadClientConfig, CreateDownloadClientDto } from "../../shared/models/download-client-config.model";
import { ClientConfig, CreateDownloadClientDto, TestDownloadClientRequest } from "../../shared/models/download-client-config.model";
import { DownloadClientType, DownloadClientTypeName } from "../../shared/models/enums";
import { DocumentationService } from "../../core/services/documentation.service";
@@ -76,6 +76,10 @@ export class DownloadClientSettingsComponent implements OnDestroy, CanComponentD
downloadClientLoading = this.downloadClientStore.loading;
downloadClientError = this.downloadClientStore.error;
downloadClientSaving = this.downloadClientStore.saving;
testing = this.downloadClientStore.testing;
testingClientId = this.downloadClientStore.testingClientId;
testError = this.downloadClientStore.testError;
testResult = this.downloadClientStore.testResult;
/**
* Check if component can be deactivated (navigation guard)
@@ -113,6 +117,22 @@ export class DownloadClientSettingsComponent implements OnDestroy, CanComponentD
.subscribe(() => {
this.onClientTypeChange();
});
// Setup effect to handle test results
effect(() => {
const testResult = this.testResult();
const testError = this.testError();
if (testResult) {
this.notificationService.showSuccess(testResult.message || 'Connection test successful');
this.downloadClientStore.resetTestState();
}
if (testError) {
this.notificationService.showError(testError);
this.downloadClientStore.resetTestState();
}
});
}
/**
@@ -372,4 +392,43 @@ export class DownloadClientSettingsComponent implements OnDestroy, CanComponentD
openFieldDocs(fieldName: string): void {
this.documentationService.openFieldDocumentation('download-client', fieldName);
}
/**
* Test client connection from the modal (new or editing)
*/
testClientFromModal(): void {
if (this.clientForm.invalid) {
this.markFormGroupTouched(this.clientForm);
this.notificationService.showError('Please fix the validation errors before testing');
return;
}
const formValue = this.clientForm.value;
const testRequest: TestDownloadClientRequest = {
typeName: formValue.typeName,
type: this.mapTypeNameToType(formValue.typeName),
host: formValue.host,
username: formValue.username,
password: formValue.password,
urlBase: formValue.urlBase,
};
this.downloadClientStore.testClient({ request: testRequest, clientId: this.editingClient?.id });
}
/**
* Test client connection from the list view (existing client)
*/
testClientFromList(client: ClientConfig): void {
const testRequest: TestDownloadClientRequest = {
typeName: client.typeName,
type: client.type,
host: client.host,
username: client.username,
password: client.password,
urlBase: client.urlBase,
};
this.downloadClientStore.testClient({ request: testRequest, clientId: client.id });
}
}

View File

@@ -4,7 +4,8 @@ import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { LidarrConfig } from '../../shared/models/lidarr-config.model';
import { ConfigurationService } from '../../core/services/configuration.service';
import { EMPTY, Observable, catchError, switchMap, tap, forkJoin, of } from 'rxjs';
import { ArrInstance, CreateArrInstanceDto } from '../../shared/models/arr-config.model';
import { ArrInstance, CreateArrInstanceDto, TestArrInstanceRequest } from '../../shared/models/arr-config.model';
import { TestConnectionResult } from '../../shared/models/download-client-config.model';
export interface LidarrConfigState {
config: LidarrConfig | null;
@@ -12,6 +13,10 @@ export interface LidarrConfigState {
saving: boolean;
error: string | null;
instanceOperations: number;
testing: boolean;
testingInstanceId: string | null;
testError: string | null;
testResult: TestConnectionResult | null;
}
const initialState: LidarrConfigState = {
@@ -19,7 +24,11 @@ const initialState: LidarrConfigState = {
loading: false,
saving: false,
error: null,
instanceOperations: 0
instanceOperations: 0,
testing: false,
testingInstanceId: null,
testError: null,
testResult: null
};
@Injectable()
@@ -123,6 +132,40 @@ export class LidarrConfigStore extends signalStore(
patchState(store, { error: null });
},
/**
* Reset test state
*/
resetTestState() {
patchState(store, { testing: false, testingInstanceId: null, testError: null, testResult: null });
},
/**
* Test a Lidarr instance connection
*/
testInstance: rxMethod<{ request: TestArrInstanceRequest; instanceId?: string }>(
(params$: Observable<{ request: TestArrInstanceRequest; instanceId?: string }>) => params$.pipe(
tap(({ instanceId }) => patchState(store, { testing: true, testingInstanceId: instanceId || null, testError: null, testResult: null })),
switchMap(({ request }) => configService.testLidarrInstance(request).pipe(
tap({
next: (result) => {
patchState(store, {
testing: false,
testResult: result,
testError: null
});
},
error: (error) => {
patchState(store, {
testing: false,
testError: error.message || 'Connection test failed'
});
}
}),
catchError(() => EMPTY)
))
)
),
// ===== INSTANCE MANAGEMENT =====
/**

View File

@@ -111,20 +111,31 @@
</div>
</div>
<div class="item-actions">
<button
pButton
type="button"
icon="pi pi-pencil"
<button
pButton
type="button"
icon="pi pi-play"
class="p-button-rounded p-button-text p-button-sm mr-1"
pTooltip="Test connection"
tooltipPosition="left"
[disabled]="testing() || lidarrSaving()"
[loading]="testingInstanceId() === instance.id"
(click)="testInstanceFromList(instance)"
></button>
<button
pButton
type="button"
icon="pi pi-pencil"
class="p-button-rounded p-button-text p-button-sm mr-1"
pTooltip="Edit instance"
tooltipPosition="left"
[disabled]="lidarrSaving()"
(click)="openEditInstanceModal(instance)"
></button>
<button
pButton
type="button"
icon="pi pi-trash"
<button
pButton
type="button"
icon="pi pi-trash"
class="p-button-rounded p-button-text p-button-danger p-button-sm"
pTooltip="Delete instance"
tooltipPosition="left"
@@ -216,19 +227,29 @@
<ng-template pTemplate="footer">
<div class="modal-footer">
<button
pButton
type="button"
label="Cancel"
<button
pButton
type="button"
label="Cancel"
class="p-button-text"
(click)="closeInstanceModal()"
></button>
<button
pButton
type="button"
label="Save"
<button
pButton
type="button"
label="Test"
icon="pi pi-play"
class="p-button-outlined ml-2"
[disabled]="instanceForm.invalid || testing()"
[loading]="testing()"
(click)="testInstanceFromModal()"
></button>
<button
pButton
type="button"
label="Save"
icon="pi pi-save"
class="p-button-primary"
class="p-button-primary ml-2"
[disabled]="instanceForm.invalid || lidarrSaving()"
[loading]="lidarrSaving()"
(click)="saveInstance()"

View File

@@ -5,7 +5,7 @@ import { Subject, takeUntil } from "rxjs";
import { LidarrConfigStore } from "./lidarr-config.store";
import { CanComponentDeactivate } from "../../core/guards";
import { LidarrConfig } from "../../shared/models/lidarr-config.model";
import { CreateArrInstanceDto, ArrInstance } from "../../shared/models/arr-config.model";
import { CreateArrInstanceDto, ArrInstance, TestArrInstanceRequest } from "../../shared/models/arr-config.model";
// PrimeNG Components
import { CardModule } from "primeng/card";
@@ -74,6 +74,10 @@ export class LidarrSettingsComponent implements OnDestroy, CanComponentDeactivat
lidarrLoading = this.lidarrStore.loading;
lidarrError = this.lidarrStore.error;
lidarrSaving = this.lidarrStore.saving;
testing = this.lidarrStore.testing;
testingInstanceId = this.lidarrStore.testingInstanceId;
testError = this.lidarrStore.testError;
testResult = this.lidarrStore.testResult;
/**
* Check if component can be deactivated (navigation guard)
@@ -112,6 +116,22 @@ export class LidarrSettingsComponent implements OnDestroy, CanComponentDeactivat
.subscribe(() => {
this.hasGlobalChanges = this.globalFormValuesChanged();
});
// Setup effect to handle test results
effect(() => {
const testResult = this.testResult();
const testError = this.testError();
if (testResult) {
this.notificationService.showSuccess(testResult.message || 'Connection test successful');
this.lidarrStore.resetTestState();
}
if (testError) {
this.notificationService.showError(testError);
this.lidarrStore.resetTestState();
}
});
}
/**
@@ -418,4 +438,34 @@ export class LidarrSettingsComponent implements OnDestroy, CanComponentDeactivat
get modalTitle(): string {
return this.modalMode === 'add' ? 'Add Lidarr Instance' : 'Edit Lidarr Instance';
}
/**
* Test instance connection from the modal (new or editing)
*/
testInstanceFromModal(): void {
if (this.instanceForm.invalid) {
this.markFormGroupTouched(this.instanceForm);
this.notificationService.showError('Please fix the validation errors before testing');
return;
}
const testRequest: TestArrInstanceRequest = {
url: this.instanceForm.get('url')?.value,
apiKey: this.instanceForm.get('apiKey')?.value,
};
this.lidarrStore.testInstance({ request: testRequest, instanceId: this.editingInstance?.id });
}
/**
* Test instance connection from the list view (existing instance)
*/
testInstanceFromList(instance: ArrInstance): void {
const testRequest: TestArrInstanceRequest = {
url: instance.url,
apiKey: instance.apiKey,
};
this.lidarrStore.testInstance({ request: testRequest, instanceId: instance.id });
}
}

View File

@@ -211,25 +211,17 @@ export class NotificationProviderConfigStore extends signalStore(
},
error: (error) => {
const errorMessage = ErrorHandlerUtil.extractErrorMessage(error);
patchState(store, {
testing: false,
testError: errorMessage, // Test errors should NOT trigger "Not connected" state
testResult: {
success: false,
message: errorMessage
}
patchState(store, {
testing: false,
testError: errorMessage // Test errors should NOT trigger "Not connected" state
});
}
}),
catchError((error) => {
const errorMessage = ErrorHandlerUtil.extractErrorMessage(error);
patchState(store, {
testing: false,
testError: errorMessage, // Test errors should NOT trigger "Not connected" state
testResult: {
success: false,
message: errorMessage
}
patchState(store, {
testing: false,
testError: errorMessage // Test errors should NOT trigger "Not connected" state
});
return EMPTY;
})

View File

@@ -130,16 +130,11 @@ export class NotificationSettingsComponent implements OnDestroy, CanComponentDea
}
});
// Setup effect to react to test results
// Setup effect to react to test results (HTTP 200 = success)
effect(() => {
const result = this.testResult();
if (result) {
if (result.success) {
this.notificationService.showSuccess(result.message || "Test notification sent successfully");
} else {
// Error handling is already done in the test error effect above
// This just handles the success case
}
this.notificationService.showSuccess(result.message || "Test notification sent successfully");
}
});
}

View File

@@ -4,7 +4,8 @@ import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { RadarrConfig } from '../../shared/models/radarr-config.model';
import { ConfigurationService } from '../../core/services/configuration.service';
import { EMPTY, Observable, catchError, switchMap, tap, forkJoin, of } from 'rxjs';
import { ArrInstance, CreateArrInstanceDto } from '../../shared/models/arr-config.model';
import { ArrInstance, CreateArrInstanceDto, TestArrInstanceRequest } from '../../shared/models/arr-config.model';
import { TestConnectionResult } from '../../shared/models/download-client-config.model';
export interface RadarrConfigState {
config: RadarrConfig | null;
@@ -12,6 +13,10 @@ export interface RadarrConfigState {
saving: boolean;
error: string | null;
instanceOperations: number;
testing: boolean;
testingInstanceId: string | null;
testError: string | null;
testResult: TestConnectionResult | null;
}
const initialState: RadarrConfigState = {
@@ -19,7 +24,11 @@ const initialState: RadarrConfigState = {
loading: false,
saving: false,
error: null,
instanceOperations: 0
instanceOperations: 0,
testing: false,
testingInstanceId: null,
testError: null,
testResult: null
};
@Injectable()
@@ -123,6 +132,40 @@ export class RadarrConfigStore extends signalStore(
patchState(store, { error: null });
},
/**
* Reset test state
*/
resetTestState() {
patchState(store, { testing: false, testingInstanceId: null, testError: null, testResult: null });
},
/**
* Test a Radarr instance connection
*/
testInstance: rxMethod<{ request: TestArrInstanceRequest; instanceId?: string }>(
(params$: Observable<{ request: TestArrInstanceRequest; instanceId?: string }>) => params$.pipe(
tap(({ instanceId }) => patchState(store, { testing: true, testingInstanceId: instanceId || null, testError: null, testResult: null })),
switchMap(({ request }) => configService.testRadarrInstance(request).pipe(
tap({
next: (result) => {
patchState(store, {
testing: false,
testResult: result,
testError: null
});
},
error: (error) => {
patchState(store, {
testing: false,
testError: error.message || 'Connection test failed'
});
}
}),
catchError(() => EMPTY)
))
)
),
// ===== INSTANCE MANAGEMENT =====
/**

View File

@@ -111,20 +111,31 @@
</div>
</div>
<div class="item-actions">
<button
pButton
type="button"
icon="pi pi-pencil"
<button
pButton
type="button"
icon="pi pi-play"
class="p-button-rounded p-button-text p-button-sm mr-1"
pTooltip="Test connection"
tooltipPosition="left"
[disabled]="testing() || radarrSaving()"
[loading]="testingInstanceId() === instance.id"
(click)="testInstanceFromList(instance)"
></button>
<button
pButton
type="button"
icon="pi pi-pencil"
class="p-button-rounded p-button-text p-button-sm mr-1"
pTooltip="Edit instance"
tooltipPosition="left"
[disabled]="radarrSaving()"
(click)="openEditInstanceModal(instance)"
></button>
<button
pButton
type="button"
icon="pi pi-trash"
<button
pButton
type="button"
icon="pi pi-trash"
class="p-button-rounded p-button-text p-button-danger p-button-sm"
pTooltip="Delete instance"
tooltipPosition="left"
@@ -216,17 +227,27 @@
<ng-template pTemplate="footer">
<div class="modal-footer">
<button
pButton
type="button"
label="Cancel"
<button
pButton
type="button"
label="Cancel"
class="p-button-text"
(click)="closeInstanceModal()"
></button>
<button
pButton
type="button"
label="Save"
<button
pButton
type="button"
label="Test"
icon="pi pi-play"
class="p-button-outlined ml-2"
[disabled]="instanceForm.invalid || testing()"
[loading]="testing()"
(click)="testInstanceFromModal()"
></button>
<button
pButton
type="button"
label="Save"
icon="pi pi-save"
class="p-button-primary ml-2"
[disabled]="instanceForm.invalid || radarrSaving()"

View File

@@ -5,7 +5,7 @@ import { Subject, takeUntil } from "rxjs";
import { RadarrConfigStore } from "./radarr-config.store";
import { CanComponentDeactivate } from "../../core/guards";
import { RadarrConfig } from "../../shared/models/radarr-config.model";
import { CreateArrInstanceDto, ArrInstance } from "../../shared/models/arr-config.model";
import { CreateArrInstanceDto, ArrInstance, TestArrInstanceRequest } from "../../shared/models/arr-config.model";
// PrimeNG Components
import { CardModule } from "primeng/card";
@@ -74,6 +74,10 @@ export class RadarrSettingsComponent implements OnDestroy, CanComponentDeactivat
radarrLoading = this.radarrStore.loading;
radarrError = this.radarrStore.error;
radarrSaving = this.radarrStore.saving;
testing = this.radarrStore.testing;
testingInstanceId = this.radarrStore.testingInstanceId;
testError = this.radarrStore.testError;
testResult = this.radarrStore.testResult;
/**
* Check if component can be deactivated (navigation guard)
@@ -112,6 +116,22 @@ export class RadarrSettingsComponent implements OnDestroy, CanComponentDeactivat
.subscribe(() => {
this.hasGlobalChanges = this.globalFormValuesChanged();
});
// Setup effect to handle test results
effect(() => {
const testResult = this.testResult();
const testError = this.testError();
if (testResult) {
this.notificationService.showSuccess(testResult.message || 'Connection test successful');
this.radarrStore.resetTestState();
}
if (testError) {
this.notificationService.showError(testError);
this.radarrStore.resetTestState();
}
});
}
/**
@@ -418,4 +438,34 @@ export class RadarrSettingsComponent implements OnDestroy, CanComponentDeactivat
get modalTitle(): string {
return this.modalMode === 'add' ? 'Add Radarr Instance' : 'Edit Radarr Instance';
}
/**
* Test instance connection from the modal (new or editing)
*/
testInstanceFromModal(): void {
if (this.instanceForm.invalid) {
this.markFormGroupTouched(this.instanceForm);
this.notificationService.showError('Please fix the validation errors before testing');
return;
}
const testRequest: TestArrInstanceRequest = {
url: this.instanceForm.get('url')?.value,
apiKey: this.instanceForm.get('apiKey')?.value,
};
this.radarrStore.testInstance({ request: testRequest, instanceId: this.editingInstance?.id });
}
/**
* Test instance connection from the list view (existing instance)
*/
testInstanceFromList(instance: ArrInstance): void {
const testRequest: TestArrInstanceRequest = {
url: instance.url,
apiKey: instance.apiKey,
};
this.radarrStore.testInstance({ request: testRequest, instanceId: instance.id });
}
}

View File

@@ -4,7 +4,8 @@ import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { ReadarrConfig } from '../../shared/models/readarr-config.model';
import { ConfigurationService } from '../../core/services/configuration.service';
import { EMPTY, Observable, catchError, switchMap, tap, forkJoin, of } from 'rxjs';
import { ArrInstance, CreateArrInstanceDto } from '../../shared/models/arr-config.model';
import { ArrInstance, CreateArrInstanceDto, TestArrInstanceRequest } from '../../shared/models/arr-config.model';
import { TestConnectionResult } from '../../shared/models/download-client-config.model';
export interface ReadarrConfigState {
config: ReadarrConfig | null;
@@ -12,6 +13,10 @@ export interface ReadarrConfigState {
saving: boolean;
error: string | null;
instanceOperations: number;
testing: boolean;
testingInstanceId: string | null;
testError: string | null;
testResult: TestConnectionResult | null;
}
const initialState: ReadarrConfigState = {
@@ -19,7 +24,11 @@ const initialState: ReadarrConfigState = {
loading: false,
saving: false,
error: null,
instanceOperations: 0
instanceOperations: 0,
testing: false,
testingInstanceId: null,
testError: null,
testResult: null
};
@Injectable()
@@ -123,6 +132,40 @@ export class ReadarrConfigStore extends signalStore(
patchState(store, { error: null });
},
/**
* Reset test state
*/
resetTestState() {
patchState(store, { testing: false, testingInstanceId: null, testError: null, testResult: null });
},
/**
* Test a Readarr instance connection
*/
testInstance: rxMethod<{ request: TestArrInstanceRequest; instanceId?: string }>(
(params$: Observable<{ request: TestArrInstanceRequest; instanceId?: string }>) => params$.pipe(
tap(({ instanceId }) => patchState(store, { testing: true, testingInstanceId: instanceId || null, testError: null, testResult: null })),
switchMap(({ request }) => configService.testReadarrInstance(request).pipe(
tap({
next: (result) => {
patchState(store, {
testing: false,
testResult: result,
testError: null
});
},
error: (error) => {
patchState(store, {
testing: false,
testError: error.message || 'Connection test failed'
});
}
}),
catchError(() => EMPTY)
))
)
),
// ===== INSTANCE MANAGEMENT =====
/**

View File

@@ -111,20 +111,31 @@
</div>
</div>
<div class="item-actions">
<button
pButton
type="button"
icon="pi pi-pencil"
<button
pButton
type="button"
icon="pi pi-play"
class="p-button-rounded p-button-text p-button-sm mr-1"
pTooltip="Test connection"
tooltipPosition="left"
[disabled]="testing() || readarrSaving()"
[loading]="testingInstanceId() === instance.id"
(click)="testInstanceFromList(instance)"
></button>
<button
pButton
type="button"
icon="pi pi-pencil"
class="p-button-rounded p-button-text p-button-sm mr-1"
pTooltip="Edit instance"
tooltipPosition="left"
[disabled]="readarrSaving()"
(click)="openEditInstanceModal(instance)"
></button>
<button
pButton
type="button"
icon="pi pi-trash"
<button
pButton
type="button"
icon="pi pi-trash"
class="p-button-rounded p-button-text p-button-danger p-button-sm"
pTooltip="Delete instance"
tooltipPosition="left"
@@ -216,17 +227,27 @@
<ng-template pTemplate="footer">
<div class="modal-footer">
<button
pButton
type="button"
label="Cancel"
<button
pButton
type="button"
label="Cancel"
class="p-button-text"
(click)="closeInstanceModal()"
></button>
<button
pButton
type="button"
label="Save"
<button
pButton
type="button"
label="Test"
icon="pi pi-play"
class="p-button-outlined ml-2"
[disabled]="instanceForm.invalid || testing()"
[loading]="testing()"
(click)="testInstanceFromModal()"
></button>
<button
pButton
type="button"
label="Save"
icon="pi pi-save"
class="p-button-primary ml-2"
[disabled]="instanceForm.invalid || readarrSaving()"

View File

@@ -5,7 +5,7 @@ import { Subject, takeUntil } from "rxjs";
import { ReadarrConfigStore } from "./readarr-config.store";
import { CanComponentDeactivate } from "../../core/guards";
import { ReadarrConfig } from "../../shared/models/readarr-config.model";
import { CreateArrInstanceDto, ArrInstance } from "../../shared/models/arr-config.model";
import { CreateArrInstanceDto, ArrInstance, TestArrInstanceRequest } from "../../shared/models/arr-config.model";
// PrimeNG Components
import { CardModule } from "primeng/card";
@@ -74,6 +74,10 @@ export class ReadarrSettingsComponent implements OnDestroy, CanComponentDeactiva
readarrLoading = this.readarrStore.loading;
readarrError = this.readarrStore.error;
readarrSaving = this.readarrStore.saving;
testing = this.readarrStore.testing;
testingInstanceId = this.readarrStore.testingInstanceId;
testError = this.readarrStore.testError;
testResult = this.readarrStore.testResult;
/**
* Check if component can be deactivated (navigation guard)
@@ -112,6 +116,22 @@ export class ReadarrSettingsComponent implements OnDestroy, CanComponentDeactiva
.subscribe(() => {
this.hasGlobalChanges = this.globalFormValuesChanged();
});
// Setup effect to handle test results
effect(() => {
const testResult = this.testResult();
const testError = this.testError();
if (testResult) {
this.notificationService.showSuccess(testResult.message || 'Connection test successful');
this.readarrStore.resetTestState();
}
if (testError) {
this.notificationService.showError(testError);
this.readarrStore.resetTestState();
}
});
}
/**
@@ -418,4 +438,34 @@ export class ReadarrSettingsComponent implements OnDestroy, CanComponentDeactiva
const control = parentControl.get(controlName);
return control ? control.dirty && control.hasError(errorName) : false;
}
/**
* Test instance connection from the modal (new or editing)
*/
testInstanceFromModal(): void {
if (this.instanceForm.invalid) {
this.markFormGroupTouched(this.instanceForm);
this.notificationService.showError('Please fix the validation errors before testing');
return;
}
const testRequest: TestArrInstanceRequest = {
url: this.instanceForm.get('url')?.value,
apiKey: this.instanceForm.get('apiKey')?.value,
};
this.readarrStore.testInstance({ request: testRequest, instanceId: this.editingInstance?.id });
}
/**
* Test instance connection from the list view (existing instance)
*/
testInstanceFromList(instance: ArrInstance): void {
const testRequest: TestArrInstanceRequest = {
url: instance.url,
apiKey: instance.apiKey,
};
this.readarrStore.testInstance({ request: testRequest, instanceId: instance.id });
}
}

View File

@@ -4,7 +4,8 @@ import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { SonarrConfig } from '../../shared/models/sonarr-config.model';
import { ConfigurationService } from '../../core/services/configuration.service';
import { EMPTY, Observable, catchError, switchMap, tap, forkJoin, of } from 'rxjs';
import { ArrInstance, CreateArrInstanceDto } from '../../shared/models/arr-config.model';
import { ArrInstance, CreateArrInstanceDto, TestArrInstanceRequest } from '../../shared/models/arr-config.model';
import { TestConnectionResult } from '../../shared/models/download-client-config.model';
export interface SonarrConfigState {
config: SonarrConfig | null;
@@ -12,6 +13,10 @@ export interface SonarrConfigState {
saving: boolean;
error: string | null;
instanceOperations: number;
testing: boolean;
testingInstanceId: string | null;
testError: string | null;
testResult: TestConnectionResult | null;
}
const initialState: SonarrConfigState = {
@@ -19,7 +24,11 @@ const initialState: SonarrConfigState = {
loading: false,
saving: false,
error: null,
instanceOperations: 0
instanceOperations: 0,
testing: false,
testingInstanceId: null,
testError: null,
testResult: null
};
@Injectable()
@@ -97,6 +106,40 @@ export class SonarrConfigStore extends signalStore(
patchState(store, { error: null });
},
/**
* Reset test state
*/
resetTestState() {
patchState(store, { testing: false, testingInstanceId: null, testError: null, testResult: null });
},
/**
* Test a Sonarr instance connection
*/
testInstance: rxMethod<{ request: TestArrInstanceRequest; instanceId?: string }>(
(params$: Observable<{ request: TestArrInstanceRequest; instanceId?: string }>) => params$.pipe(
tap(({ instanceId }) => patchState(store, { testing: true, testingInstanceId: instanceId || null, testError: null, testResult: null })),
switchMap(({ request }) => configService.testSonarrInstance(request).pipe(
tap({
next: (result) => {
patchState(store, {
testing: false,
testResult: result,
testError: null
});
},
error: (error) => {
patchState(store, {
testing: false,
testError: error.message || 'Connection test failed'
});
}
}),
catchError(() => EMPTY)
))
)
),
// ===== INSTANCE MANAGEMENT =====
/**

View File

@@ -111,20 +111,31 @@
</div>
</div>
<div class="item-actions">
<button
pButton
type="button"
icon="pi pi-pencil"
<button
pButton
type="button"
icon="pi pi-play"
class="p-button-rounded p-button-text p-button-sm mr-1"
pTooltip="Test connection"
tooltipPosition="left"
[disabled]="testing() || sonarrSaving()"
[loading]="testingInstanceId() === instance.id"
(click)="testInstanceFromList(instance)"
></button>
<button
pButton
type="button"
icon="pi pi-pencil"
class="p-button-rounded p-button-text p-button-sm mr-1"
pTooltip="Edit instance"
tooltipPosition="left"
[disabled]="sonarrSaving()"
(click)="openEditInstanceModal(instance)"
></button>
<button
pButton
type="button"
icon="pi pi-trash"
<button
pButton
type="button"
icon="pi pi-trash"
class="p-button-rounded p-button-text p-button-danger p-button-sm"
pTooltip="Delete instance"
tooltipPosition="left"
@@ -216,17 +227,27 @@
<ng-template pTemplate="footer">
<div class="modal-footer">
<button
pButton
type="button"
label="Cancel"
<button
pButton
type="button"
label="Cancel"
class="p-button-text"
(click)="closeInstanceModal()"
></button>
<button
pButton
type="button"
label="Save"
<button
pButton
type="button"
label="Test"
icon="pi pi-play"
class="p-button-outlined ml-2"
[disabled]="instanceForm.invalid || testing()"
[loading]="testing()"
(click)="testInstanceFromModal()"
></button>
<button
pButton
type="button"
label="Save"
icon="pi pi-save"
class="p-button-primary ml-2"
[disabled]="instanceForm.invalid || sonarrSaving()"

View File

@@ -5,7 +5,7 @@ import { Subject, takeUntil } from "rxjs";
import { SonarrConfigStore } from "./sonarr-config.store";
import { CanComponentDeactivate } from "../../core/guards";
import { SonarrConfig } from "../../shared/models/sonarr-config.model";
import { CreateArrInstanceDto, ArrInstance } from "../../shared/models/arr-config.model";
import { CreateArrInstanceDto, ArrInstance, TestArrInstanceRequest } from "../../shared/models/arr-config.model";
// PrimeNG Components
import { CardModule } from "primeng/card";
@@ -74,6 +74,10 @@ export class SonarrSettingsComponent implements OnDestroy, CanComponentDeactivat
sonarrLoading = this.sonarrStore.loading;
sonarrError = this.sonarrStore.error;
sonarrSaving = this.sonarrStore.saving;
testing = this.sonarrStore.testing;
testingInstanceId = this.sonarrStore.testingInstanceId;
testError = this.sonarrStore.testError;
testResult = this.sonarrStore.testResult;
/**
* Check if component can be deactivated (navigation guard)
@@ -112,6 +116,22 @@ export class SonarrSettingsComponent implements OnDestroy, CanComponentDeactivat
.subscribe(() => {
this.hasGlobalChanges = this.globalFormValuesChanged();
});
// Setup effect to handle test results
effect(() => {
const testResult = this.testResult();
const testError = this.testError();
if (testResult) {
this.notificationService.showSuccess(testResult.message || 'Connection test successful');
this.sonarrStore.resetTestState();
}
if (testError) {
this.notificationService.showError(testError);
this.sonarrStore.resetTestState();
}
});
}
/**
@@ -418,4 +438,34 @@ export class SonarrSettingsComponent implements OnDestroy, CanComponentDeactivat
get modalTitle(): string {
return this.modalMode === 'add' ? 'Add Sonarr Instance' : 'Edit Sonarr Instance';
}
/**
* Test instance connection from the modal (new or editing)
*/
testInstanceFromModal(): void {
if (this.instanceForm.invalid) {
this.markFormGroupTouched(this.instanceForm);
this.notificationService.showError('Please fix the validation errors before testing');
return;
}
const testRequest: TestArrInstanceRequest = {
url: this.instanceForm.get('url')?.value,
apiKey: this.instanceForm.get('apiKey')?.value,
};
this.sonarrStore.testInstance({ request: testRequest, instanceId: this.editingInstance?.id });
}
/**
* Test instance connection from the list view (existing instance)
*/
testInstanceFromList(instance: ArrInstance): void {
const testRequest: TestArrInstanceRequest = {
url: instance.url,
apiKey: instance.apiKey,
};
this.sonarrStore.testInstance({ request: testRequest, instanceId: instance.id });
}
}

View File

@@ -4,7 +4,8 @@ import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { WhisparrConfig } from '../../shared/models/whisparr-config.model';
import { ConfigurationService } from '../../core/services/configuration.service';
import { EMPTY, Observable, catchError, switchMap, tap, forkJoin, of } from 'rxjs';
import { ArrInstance, CreateArrInstanceDto } from '../../shared/models/arr-config.model';
import { ArrInstance, CreateArrInstanceDto, TestArrInstanceRequest } from '../../shared/models/arr-config.model';
import { TestConnectionResult } from '../../shared/models/download-client-config.model';
export interface WhisparrConfigState {
config: WhisparrConfig | null;
@@ -12,6 +13,10 @@ export interface WhisparrConfigState {
saving: boolean;
error: string | null;
instanceOperations: number;
testing: boolean;
testingInstanceId: string | null;
testError: string | null;
testResult: TestConnectionResult | null;
}
const initialState: WhisparrConfigState = {
@@ -19,7 +24,11 @@ const initialState: WhisparrConfigState = {
loading: false,
saving: false,
error: null,
instanceOperations: 0
instanceOperations: 0,
testing: false,
testingInstanceId: null,
testError: null,
testResult: null
};
@Injectable()
@@ -123,6 +132,40 @@ export class WhisparrConfigStore extends signalStore(
patchState(store, { error: null });
},
/**
* Reset test state
*/
resetTestState() {
patchState(store, { testing: false, testingInstanceId: null, testError: null, testResult: null });
},
/**
* Test a Whisparr instance connection
*/
testInstance: rxMethod<{ request: TestArrInstanceRequest; instanceId?: string }>(
(params$: Observable<{ request: TestArrInstanceRequest; instanceId?: string }>) => params$.pipe(
tap(({ instanceId }) => patchState(store, { testing: true, testingInstanceId: instanceId || null, testError: null, testResult: null })),
switchMap(({ request }) => configService.testWhisparrInstance(request).pipe(
tap({
next: (result) => {
patchState(store, {
testing: false,
testResult: result,
testError: null
});
},
error: (error) => {
patchState(store, {
testing: false,
testError: error.message || 'Connection test failed'
});
}
}),
catchError(() => EMPTY)
))
)
),
// ===== INSTANCE MANAGEMENT =====
/**

View File

@@ -111,20 +111,31 @@
</div>
</div>
<div class="item-actions">
<button
pButton
type="button"
icon="pi pi-pencil"
<button
pButton
type="button"
icon="pi pi-play"
class="p-button-rounded p-button-text p-button-sm mr-1"
pTooltip="Test connection"
tooltipPosition="left"
[disabled]="testing() || whisparrSaving()"
[loading]="testingInstanceId() === instance.id"
(click)="testInstanceFromList(instance)"
></button>
<button
pButton
type="button"
icon="pi pi-pencil"
class="p-button-rounded p-button-text p-button-sm mr-1"
pTooltip="Edit instance"
tooltipPosition="left"
[disabled]="whisparrSaving()"
(click)="openEditInstanceModal(instance)"
></button>
<button
pButton
type="button"
icon="pi pi-trash"
<button
pButton
type="button"
icon="pi pi-trash"
class="p-button-rounded p-button-text p-button-danger p-button-sm"
pTooltip="Delete instance"
tooltipPosition="left"
@@ -216,17 +227,27 @@
<ng-template pTemplate="footer">
<div class="modal-footer">
<button
pButton
type="button"
label="Cancel"
<button
pButton
type="button"
label="Cancel"
class="p-button-text"
(click)="closeInstanceModal()"
></button>
<button
pButton
type="button"
label="Save"
<button
pButton
type="button"
label="Test"
icon="pi pi-play"
class="p-button-outlined ml-2"
[disabled]="instanceForm.invalid || testing()"
[loading]="testing()"
(click)="testInstanceFromModal()"
></button>
<button
pButton
type="button"
label="Save"
icon="pi pi-save"
class="p-button-primary ml-2"
[disabled]="instanceForm.invalid || whisparrSaving()"

View File

@@ -5,7 +5,7 @@ import { Subject, takeUntil } from "rxjs";
import { WhisparrConfigStore } from "./whisparr-config.store";
import { CanComponentDeactivate } from "../../core/guards";
import { WhisparrConfig } from "../../shared/models/whisparr-config.model";
import { CreateArrInstanceDto, ArrInstance } from "../../shared/models/arr-config.model";
import { CreateArrInstanceDto, ArrInstance, TestArrInstanceRequest } from "../../shared/models/arr-config.model";
// PrimeNG Components
import { CardModule } from "primeng/card";
@@ -74,6 +74,10 @@ export class WhisparrSettingsComponent implements OnDestroy, CanComponentDeactiv
whisparrLoading = this.whisparrStore.loading;
whisparrError = this.whisparrStore.error;
whisparrSaving = this.whisparrStore.saving;
testing = this.whisparrStore.testing;
testingInstanceId = this.whisparrStore.testingInstanceId;
testError = this.whisparrStore.testError;
testResult = this.whisparrStore.testResult;
/**
* Check if component can be deactivated (navigation guard)
@@ -112,6 +116,22 @@ export class WhisparrSettingsComponent implements OnDestroy, CanComponentDeactiv
.subscribe(() => {
this.hasGlobalChanges = this.globalFormValuesChanged();
});
// Setup effect to handle test results
effect(() => {
const testResult = this.testResult();
const testError = this.testError();
if (testResult) {
this.notificationService.showSuccess(testResult.message || 'Connection test successful');
this.whisparrStore.resetTestState();
}
if (testError) {
this.notificationService.showError(testError);
this.whisparrStore.resetTestState();
}
});
}
/**
@@ -413,4 +433,34 @@ export class WhisparrSettingsComponent implements OnDestroy, CanComponentDeactiv
get modalTitle(): string {
return this.modalMode === 'add' ? 'Add Whisparr Instance' : 'Edit Whisparr Instance';
}
/**
* Test instance connection from the modal (new or editing)
*/
testInstanceFromModal(): void {
if (this.instanceForm.invalid) {
this.markFormGroupTouched(this.instanceForm);
this.notificationService.showError('Please fix the validation errors before testing');
return;
}
const testRequest: TestArrInstanceRequest = {
url: this.instanceForm.get('url')?.value,
apiKey: this.instanceForm.get('apiKey')?.value,
};
this.whisparrStore.testInstance({ request: testRequest, instanceId: this.editingInstance?.id });
}
/**
* Test instance connection from the list view (existing instance)
*/
testInstanceFromList(instance: ArrInstance): void {
const testRequest: TestArrInstanceRequest = {
url: instance.url,
apiKey: instance.apiKey,
};
this.whisparrStore.testInstance({ request: testRequest, instanceId: instance.id });
}
}

View File

@@ -18,3 +18,11 @@ export interface CreateArrInstanceDto {
url: string;
apiKey: string;
}
/**
* Request for testing an Arr instance connection
*/
export interface TestArrInstanceRequest {
url: string;
apiKey: string;
}

View File

@@ -124,3 +124,23 @@ export interface ClientConfigUpdateDto extends ClientConfig {
*/
password: string;
}
/**
* Request for testing a download client connection
*/
export interface TestDownloadClientRequest {
typeName: DownloadClientTypeName;
type: DownloadClientType;
host?: string;
username?: string;
password?: string;
urlBase?: string;
}
/**
* Result of testing a connection (HTTP 200 = success)
*/
export interface TestConnectionResult {
message: string;
responseTime?: number;
}

View File

@@ -61,7 +61,5 @@ export interface AppriseConfiguration {
}
export interface TestNotificationResult {
success: boolean;
message: string;
error?: string;
}