From f2027f77a971944559ba295e4b9490b64d372bc2 Mon Sep 17 00:00:00 2001 From: Flaminel Date: Fri, 16 May 2025 19:16:32 +0300 Subject: [PATCH] #12 --- .../Controllers/DownloadClientsController.cs | 304 ++++++++++++++++++ .../Controllers/HealthCheckController.cs | 103 ++++++ code/Executable/DependencyInjection/ApiDI.cs | 11 + code/Executable/DependencyInjection/MainDI.cs | 13 + .../Health/HealthCheckServiceFixture.cs | 93 ++++++ .../Health/HealthCheckServiceTests.cs | 177 ++++++++++ .../Http/DynamicHttpClientProviderFixture.cs | 70 ++++ .../Http/DynamicHttpClientProviderTests.cs | 135 ++++++++ .../Factory/DownloadClientFactoryFixture.cs | 109 +++++++ .../Factory/DownloadClientFactoryTests.cs | 201 ++++++++++++ .../Health/ClientHealthChangedEventArgs.cs | 51 +++ .../Health/HealthCheckBackgroundService.cs | 89 +++++ .../Health/HealthCheckService.cs | 224 +++++++++++++ code/Infrastructure/Health/HealthStatus.cs | 42 +++ .../Health/HealthStatusBroadcaster.cs | 82 +++++ code/Infrastructure/Health/HealthStatusHub.cs | 38 +++ .../Health/IHealthCheckService.cs | 38 +++ 17 files changed, 1780 insertions(+) create mode 100644 code/Executable/Controllers/DownloadClientsController.cs create mode 100644 code/Executable/Controllers/HealthCheckController.cs create mode 100644 code/Infrastructure.Tests/Health/HealthCheckServiceFixture.cs create mode 100644 code/Infrastructure.Tests/Health/HealthCheckServiceTests.cs create mode 100644 code/Infrastructure.Tests/Http/DynamicHttpClientProviderFixture.cs create mode 100644 code/Infrastructure.Tests/Http/DynamicHttpClientProviderTests.cs create mode 100644 code/Infrastructure.Tests/Verticals/DownloadClient/Factory/DownloadClientFactoryFixture.cs create mode 100644 code/Infrastructure.Tests/Verticals/DownloadClient/Factory/DownloadClientFactoryTests.cs create mode 100644 code/Infrastructure/Health/ClientHealthChangedEventArgs.cs create mode 100644 code/Infrastructure/Health/HealthCheckBackgroundService.cs create mode 100644 code/Infrastructure/Health/HealthCheckService.cs create mode 100644 code/Infrastructure/Health/HealthStatus.cs create mode 100644 code/Infrastructure/Health/HealthStatusBroadcaster.cs create mode 100644 code/Infrastructure/Health/HealthStatusHub.cs create mode 100644 code/Infrastructure/Health/IHealthCheckService.cs diff --git a/code/Executable/Controllers/DownloadClientsController.cs b/code/Executable/Controllers/DownloadClientsController.cs new file mode 100644 index 00000000..fe98abca --- /dev/null +++ b/code/Executable/Controllers/DownloadClientsController.cs @@ -0,0 +1,304 @@ +using Common.Configuration.DownloadClient; +using Common.Enums; +using Infrastructure.Configuration; +using Infrastructure.Verticals.DownloadClient; +using Infrastructure.Verticals.DownloadClient.Factory; +using Microsoft.AspNetCore.Mvc; + +namespace Executable.Controllers; + +/// +/// Controller for managing individual download clients +/// +[ApiController] +[Route("api/clients")] +public class DownloadClientsController : ControllerBase +{ + private readonly ILogger _logger; + private readonly IConfigManager _configManager; + private readonly IDownloadClientFactory _clientFactory; + + /// + /// Initializes a new instance of the class + /// + public DownloadClientsController( + ILogger logger, + IConfigManager configManager, + IDownloadClientFactory clientFactory) + { + _logger = logger; + _configManager = configManager; + _clientFactory = clientFactory; + } + + /// + /// Gets all download clients + /// + [HttpGet] + public async Task GetAllClients() + { + try + { + var config = await _configManager.GetDownloadClientConfigAsync(); + if (config == null) + { + return NotFound(new { Message = "No download client configuration found" }); + } + + return Ok(config.Clients); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error retrieving download clients"); + return StatusCode(500, new { Error = "An error occurred while retrieving download clients" }); + } + } + + /// + /// Gets a specific download client by ID + /// + [HttpGet("{id}")] + public async Task GetClient(string id) + { + try + { + var config = await _configManager.GetDownloadClientConfigAsync(); + if (config == null) + { + return NotFound(new { Message = "No download client configuration found" }); + } + + var client = config.GetClientConfig(id); + if (client == null) + { + return NotFound(new { Message = $"Client with ID '{id}' not found" }); + } + + return Ok(client); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error retrieving download client {id}", id); + return StatusCode(500, new { Error = "An error occurred while retrieving the download client" }); + } + } + + /// + /// Adds a new download client + /// + [HttpPost] + public async Task AddClient([FromBody] ClientConfig clientConfig) + { + try + { + // Validate the new client configuration + clientConfig.Validate(); + + // Get the current configuration + var config = await _configManager.GetDownloadClientConfigAsync(); + if (config == null) + { + config = new DownloadClientConfig + { + Clients = new List() + }; + } + + // Check if a client with the same ID already exists + if (config.GetClientConfig(clientConfig.Id) != null) + { + return BadRequest(new { Error = $"A client with ID '{clientConfig.Id}' already exists" }); + } + + // Add the new client + config.Clients.Add(clientConfig); + + // Persist the updated configuration + var result = await _configManager.UpdateDownloadClientConfigAsync(config); + if (!result) + { + return StatusCode(500, new { Error = "Failed to save download client configuration" }); + } + + // Refresh the client factory to recognize the new client + await _clientFactory.RefreshClients(); + + _logger.LogInformation("Added new download client: {name} ({id})", clientConfig.Name, clientConfig.Id); + return CreatedAtAction(nameof(GetClient), new { id = clientConfig.Id }, clientConfig); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error adding download client"); + return BadRequest(new { Error = ex.Message }); + } + } + + /// + /// Updates an existing download client + /// + [HttpPut("{id}")] + public async Task UpdateClient(string id, [FromBody] ClientConfig clientConfig) + { + try + { + // Ensure the ID in the route matches the ID in the body + if (id != clientConfig.Id) + { + return BadRequest(new { Error = "Client ID in the URL does not match the ID in the request body" }); + } + + // Validate the updated client configuration + clientConfig.Validate(); + + // Get the current configuration + var config = await _configManager.GetDownloadClientConfigAsync(); + if (config == null) + { + return NotFound(new { Message = "No download client configuration found" }); + } + + // Find the client to update + var existingClientIndex = config.Clients.FindIndex(c => c.Id == id); + if (existingClientIndex == -1) + { + return NotFound(new { Message = $"Client with ID '{id}' not found" }); + } + + // Update the client + config.Clients[existingClientIndex] = clientConfig; + + // Persist the updated configuration + var result = await _configManager.UpdateDownloadClientConfigAsync(config); + if (!result) + { + return StatusCode(500, new { Error = "Failed to save download client configuration" }); + } + + // Refresh the client factory to recognize the updated client + await _clientFactory.RefreshClients(); + + _logger.LogInformation("Updated download client: {name} ({id})", clientConfig.Name, clientConfig.Id); + return Ok(clientConfig); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error updating download client {id}", id); + return BadRequest(new { Error = ex.Message }); + } + } + + /// + /// Deletes a download client + /// + [HttpDelete("{id}")] + public async Task DeleteClient(string id) + { + try + { + // Get the current configuration + var config = await _configManager.GetDownloadClientConfigAsync(); + if (config == null) + { + return NotFound(new { Message = "No download client configuration found" }); + } + + // Find the client to delete + var existingClientIndex = config.Clients.FindIndex(c => c.Id == id); + if (existingClientIndex == -1) + { + return NotFound(new { Message = $"Client with ID '{id}' not found" }); + } + + // Remove the client + config.Clients.RemoveAt(existingClientIndex); + + // Persist the updated configuration + var result = await _configManager.UpdateDownloadClientConfigAsync(config); + if (!result) + { + return StatusCode(500, new { Error = "Failed to save download client configuration" }); + } + + // Refresh the client factory to recognize the deleted client + await _clientFactory.RefreshClients(); + + _logger.LogInformation("Deleted download client with ID: {id}", id); + return NoContent(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error deleting download client {id}", id); + return StatusCode(500, new { Error = "An error occurred while deleting the download client" }); + } + } + + /// + /// Tests connection to a download client + /// + [HttpPost("{id}/test")] + public async Task TestConnection(string id) + { + try + { + // Get the client configuration + var config = await _configManager.GetDownloadClientConfigAsync(); + if (config == null) + { + return NotFound(new { Message = "No download client configuration found" }); + } + + var clientConfig = config.GetClientConfig(id); + if (clientConfig == null) + { + return NotFound(new { Message = $"Client with ID '{id}' not found" }); + } + + // Ensure the client is initialized + try + { + // Get the client instance + var client = _clientFactory.GetClient(id); + + // Try to login + await client.LoginAsync(); + + _logger.LogInformation("Successfully connected to download client: {name} ({id})", clientConfig.Name, id); + return Ok(new { Success = true, Message = "Connection successful" }); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to connect to download client: {name} ({id})", clientConfig.Name, id); + return Ok(new { Success = false, Message = $"Connection failed: {ex.Message}" }); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error testing connection to download client {id}", id); + return StatusCode(500, new { Error = "An error occurred while testing the connection" }); + } + } + + /// + /// Gets all clients of a specific type + /// + [HttpGet("type/{type}")] + public async Task GetClientsByType(DownloadClient type) + { + try + { + var config = await _configManager.GetDownloadClientConfigAsync(); + if (config == null) + { + return NotFound(new { Message = "No download client configuration found" }); + } + + var clients = config.Clients.Where(c => c.Type == type).ToList(); + return Ok(clients); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error retrieving download clients of type {type}", type); + return StatusCode(500, new { Error = "An error occurred while retrieving download clients" }); + } + } +} diff --git a/code/Executable/Controllers/HealthCheckController.cs b/code/Executable/Controllers/HealthCheckController.cs new file mode 100644 index 00000000..e883f6e2 --- /dev/null +++ b/code/Executable/Controllers/HealthCheckController.cs @@ -0,0 +1,103 @@ +using Infrastructure.Health; +using Microsoft.AspNetCore.Mvc; + +namespace Executable.Controllers; + +/// +/// Controller for checking the health of download clients +/// +[ApiController] +[Route("api/health")] +public class HealthCheckController : ControllerBase +{ + private readonly ILogger _logger; + private readonly IHealthCheckService _healthCheckService; + + /// + /// Initializes a new instance of the class + /// + public HealthCheckController( + ILogger logger, + IHealthCheckService healthCheckService) + { + _logger = logger; + _healthCheckService = healthCheckService; + } + + /// + /// Gets the health status of all download clients + /// + [HttpGet] + public IActionResult GetAllHealth() + { + try + { + var healthStatuses = _healthCheckService.GetAllClientHealth(); + return Ok(healthStatuses); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error retrieving client health statuses"); + return StatusCode(500, new { Error = "An error occurred while retrieving client health statuses" }); + } + } + + /// + /// Gets the health status of a specific download client + /// + [HttpGet("{id}")] + public IActionResult GetClientHealth(string id) + { + try + { + var healthStatus = _healthCheckService.GetClientHealth(id); + if (healthStatus == null) + { + return NotFound(new { Message = $"Health status for client with ID '{id}' not found" }); + } + + return Ok(healthStatus); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error retrieving health status for client {id}", id); + return StatusCode(500, new { Error = "An error occurred while retrieving the client health status" }); + } + } + + /// + /// Triggers a health check for all download clients + /// + [HttpPost("check")] + public async Task CheckAllHealth() + { + try + { + var results = await _healthCheckService.CheckAllClientsHealthAsync(); + return Ok(results); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error checking health for all clients"); + return StatusCode(500, new { Error = "An error occurred while checking client health" }); + } + } + + /// + /// Triggers a health check for a specific download client + /// + [HttpPost("check/{id}")] + public async Task CheckClientHealth(string id) + { + try + { + var result = await _healthCheckService.CheckClientHealthAsync(id); + return Ok(result); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error checking health for client {id}", id); + return StatusCode(500, new { Error = "An error occurred while checking client health" }); + } + } +} diff --git a/code/Executable/DependencyInjection/ApiDI.cs b/code/Executable/DependencyInjection/ApiDI.cs index 821c3b1b..ab447fe0 100644 --- a/code/Executable/DependencyInjection/ApiDI.cs +++ b/code/Executable/DependencyInjection/ApiDI.cs @@ -1,3 +1,4 @@ +using Infrastructure.Health; using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; using Microsoft.OpenApi.Models; @@ -11,6 +12,13 @@ public static class ApiDI // Add API-specific services services.AddControllers(); services.AddEndpointsApiExplorer(); + + // Add SignalR for real-time updates + services.AddSignalR(); + + // Add health status broadcaster + services.AddHostedService(); + services.AddSwaggerGen(options => { options.SwaggerDoc("v1", new OpenApiInfo @@ -45,6 +53,9 @@ public static class ApiDI app.UseHttpsRedirection(); app.UseAuthorization(); app.MapControllers(); + + // Map SignalR hubs + app.MapHub("/hubs/health"); return app; } diff --git a/code/Executable/DependencyInjection/MainDI.cs b/code/Executable/DependencyInjection/MainDI.cs index ef005a96..1f688429 100644 --- a/code/Executable/DependencyInjection/MainDI.cs +++ b/code/Executable/DependencyInjection/MainDI.cs @@ -3,6 +3,7 @@ using Common.Configuration.General; using Common.Helpers; using Domain.Models.Arr; using Infrastructure.Configuration; +using Infrastructure.Health; using Infrastructure.Http; using Infrastructure.Services; using Infrastructure.Verticals.DownloadClient.Factory; @@ -29,6 +30,7 @@ public static class MainDI }) .AddServices() .AddDownloadClientServices() + .AddHealthServices() .AddQuartzServices(configuration) .AddNotifications(configuration) .AddMassTransit(config => @@ -121,4 +123,15 @@ public static class MainDI .AddTransient() .AddTransient() .AddTransient(); + + /// + /// Adds health check services to the service collection + /// + private static IServiceCollection AddHealthServices(this IServiceCollection services) => + services + // Register the health check service + .AddSingleton() + + // Register the background service for periodic health checks + .AddHostedService(); } \ No newline at end of file diff --git a/code/Infrastructure.Tests/Health/HealthCheckServiceFixture.cs b/code/Infrastructure.Tests/Health/HealthCheckServiceFixture.cs new file mode 100644 index 00000000..ca6abf59 --- /dev/null +++ b/code/Infrastructure.Tests/Health/HealthCheckServiceFixture.cs @@ -0,0 +1,93 @@ +using Common.Configuration.DownloadClient; +using Common.Enums; +using Infrastructure.Configuration; +using Infrastructure.Health; +using Infrastructure.Verticals.DownloadClient; +using Infrastructure.Verticals.DownloadClient.Factory; +using Microsoft.Extensions.Logging; +using NSubstitute; +using NSubstitute.ExceptionExtensions; + +namespace Infrastructure.Tests.Health; + +public class HealthCheckServiceFixture : IDisposable +{ + public ILogger Logger { get; } + public IConfigManager ConfigManager { get; } + public IDownloadClientFactory ClientFactory { get; } + public IDownloadService MockClient { get; } + public DownloadClientConfig DownloadClientConfig { get; } + + public HealthCheckServiceFixture() + { + Logger = Substitute.For>(); + ConfigManager = Substitute.For(); + ClientFactory = Substitute.For(); + MockClient = Substitute.For(); + + // Set up test download client config + DownloadClientConfig = new DownloadClientConfig + { + Clients = new List + { + new() + { + Id = "qbit1", + Name = "Test QBittorrent", + Type = DownloadClient.QBittorrent, + Enabled = true, + Url = "http://localhost:8080", + Username = "admin", + Password = "adminadmin" + }, + new() + { + Id = "transmission1", + Name = "Test Transmission", + Type = DownloadClient.Transmission, + Enabled = true, + Url = "http://localhost:9091", + Username = "admin", + Password = "adminadmin" + }, + new() + { + Id = "disabled1", + Name = "Disabled Client", + Type = DownloadClient.QBittorrent, + Enabled = false, + Url = "http://localhost:5555" + } + } + }; + + // Set up the mock client factory + ClientFactory.GetClient(Arg.Any()).Returns(MockClient); + MockClient.GetClientId().Returns("qbit1"); + + // Set up mock config manager + ConfigManager.GetDownloadClientConfigAsync().Returns(DownloadClientConfig); + } + + public HealthCheckService CreateSut() + { + return new HealthCheckService(Logger, ConfigManager, ClientFactory); + } + + public void SetupHealthyClient(string clientId) + { + // Setup a client that will successfully login + MockClient.LoginAsync().Returns(Task.CompletedTask); + } + + public void SetupUnhealthyClient(string clientId, string errorMessage = "Failed to connect") + { + // Setup a client that will fail to login + MockClient.LoginAsync().Throws(new Exception(errorMessage)); + } + + public void Dispose() + { + // Cleanup if needed + } +} diff --git a/code/Infrastructure.Tests/Health/HealthCheckServiceTests.cs b/code/Infrastructure.Tests/Health/HealthCheckServiceTests.cs new file mode 100644 index 00000000..afab762f --- /dev/null +++ b/code/Infrastructure.Tests/Health/HealthCheckServiceTests.cs @@ -0,0 +1,177 @@ +using Infrastructure.Health; +using NSubstitute; +using Shouldly; + +namespace Infrastructure.Tests.Health; + +public class HealthCheckServiceTests : IClassFixture +{ + private readonly HealthCheckServiceFixture _fixture; + + public HealthCheckServiceTests(HealthCheckServiceFixture fixture) + { + _fixture = fixture; + } + + [Fact] + public async Task CheckClientHealthAsync_WithHealthyClient_ShouldReturnHealthyStatus() + { + // Arrange + var sut = _fixture.CreateSut(); + _fixture.SetupHealthyClient("qbit1"); + + // Act + var result = await sut.CheckClientHealthAsync("qbit1"); + + // Assert + result.ShouldSatisfyAllConditions( + () => result.IsHealthy.ShouldBeTrue(), + () => result.ClientId.ShouldBe("qbit1"), + () => result.ErrorMessage.ShouldBeNull(), + () => result.LastChecked.ShouldBeInRange(DateTime.UtcNow.AddSeconds(-10), DateTime.UtcNow) + ); + } + + [Fact] + public async Task CheckClientHealthAsync_WithUnhealthyClient_ShouldReturnUnhealthyStatus() + { + // Arrange + var sut = _fixture.CreateSut(); + _fixture.SetupUnhealthyClient("qbit1", "Connection refused"); + + // Act + var result = await sut.CheckClientHealthAsync("qbit1"); + + // Assert + result.ShouldSatisfyAllConditions( + () => result.IsHealthy.ShouldBeFalse(), + () => result.ClientId.ShouldBe("qbit1"), + () => result.ErrorMessage.ShouldContain("Connection refused"), + () => result.LastChecked.ShouldBeInRange(DateTime.UtcNow.AddSeconds(-10), DateTime.UtcNow) + ); + } + + [Fact] + public async Task CheckClientHealthAsync_WithNonExistentClient_ShouldReturnErrorStatus() + { + // Arrange + var sut = _fixture.CreateSut(); + + // Configure the ConfigManager to return null for the client config + _fixture.ConfigManager.GetDownloadClientConfigAsync().Returns( + Task.FromResult(null) + ); + + // Act + var result = await sut.CheckClientHealthAsync("non-existent"); + + // Assert + result.ShouldSatisfyAllConditions( + () => result.IsHealthy.ShouldBeFalse(), + () => result.ClientId.ShouldBe("non-existent"), + () => result.ErrorMessage.ShouldContain("not found"), + () => result.LastChecked.ShouldBeInRange(DateTime.UtcNow.AddSeconds(-10), DateTime.UtcNow) + ); + } + + [Fact] + public async Task CheckAllClientsHealthAsync_ShouldReturnAllEnabledClients() + { + // Arrange + var sut = _fixture.CreateSut(); + _fixture.SetupHealthyClient("qbit1"); + _fixture.SetupUnhealthyClient("transmission1"); + + // Act + var results = await sut.CheckAllClientsHealthAsync(); + + // Assert + results.Count.ShouldBe(2); // Only enabled clients + results.Keys.ShouldContain("qbit1"); + results.Keys.ShouldContain("transmission1"); + results["qbit1"].IsHealthy.ShouldBeTrue(); + results["transmission1"].IsHealthy.ShouldBeFalse(); + } + + [Fact] + public async Task ClientHealthChanged_ShouldRaiseEventOnHealthStateChange() + { + // Arrange + var sut = _fixture.CreateSut(); + _fixture.SetupHealthyClient("qbit1"); + + ClientHealthChangedEventArgs? capturedArgs = null; + sut.ClientHealthChanged += (_, args) => capturedArgs = args; + + // Act - first check establishes initial state + var firstResult = await sut.CheckClientHealthAsync("qbit1"); + + // Setup client to be unhealthy for second check + _fixture.SetupUnhealthyClient("qbit1"); + + // Act - second check changes state + var secondResult = await sut.CheckClientHealthAsync("qbit1"); + + // Assert + capturedArgs.ShouldNotBeNull(); + capturedArgs.ClientId.ShouldBe("qbit1"); + capturedArgs.Status.IsHealthy.ShouldBeFalse(); + capturedArgs.IsDegraded.ShouldBeTrue(); + capturedArgs.IsRecovered.ShouldBeFalse(); + } + + [Fact] + public async Task GetClientHealth_ShouldReturnCachedStatus() + { + // Arrange + var sut = _fixture.CreateSut(); + _fixture.SetupHealthyClient("qbit1"); + + // Perform a check to cache the status + await sut.CheckClientHealthAsync("qbit1"); + + // Act + var result = sut.GetClientHealth("qbit1"); + + // Assert + result.ShouldNotBeNull(); + result.IsHealthy.ShouldBeTrue(); + result.ClientId.ShouldBe("qbit1"); + } + + [Fact] + public void GetClientHealth_WithNoCheck_ShouldReturnNull() + { + // Arrange + var sut = _fixture.CreateSut(); + + // Act + var result = sut.GetClientHealth("qbit1"); + + // Assert + result.ShouldBeNull(); + } + + [Fact] + public async Task GetAllClientHealth_ShouldReturnAllCheckedClients() + { + // Arrange + var sut = _fixture.CreateSut(); + _fixture.SetupHealthyClient("qbit1"); + _fixture.SetupUnhealthyClient("transmission1"); + + // Perform checks to cache statuses + await sut.CheckClientHealthAsync("qbit1"); + await sut.CheckClientHealthAsync("transmission1"); + + // Act + var results = sut.GetAllClientHealth(); + + // Assert + results.Count.ShouldBe(2); + results.Keys.ShouldContain("qbit1"); + results.Keys.ShouldContain("transmission1"); + results["qbit1"].IsHealthy.ShouldBeTrue(); + results["transmission1"].IsHealthy.ShouldBeFalse(); + } +} diff --git a/code/Infrastructure.Tests/Http/DynamicHttpClientProviderFixture.cs b/code/Infrastructure.Tests/Http/DynamicHttpClientProviderFixture.cs new file mode 100644 index 00000000..c818276a --- /dev/null +++ b/code/Infrastructure.Tests/Http/DynamicHttpClientProviderFixture.cs @@ -0,0 +1,70 @@ +using Common.Configuration.DownloadClient; +using Common.Enums; +using Infrastructure.Http; +using Microsoft.Extensions.Logging; +using NSubstitute; + +namespace Infrastructure.Tests.Http; + +public class DynamicHttpClientProviderFixture : IDisposable +{ + public ILogger Logger { get; } + + public DynamicHttpClientProviderFixture() + { + Logger = Substitute.For>(); + } + + public DynamicHttpClientProvider CreateSut() + { + return new DynamicHttpClientProvider(Logger); + } + + public ClientConfig CreateQBitClientConfig() + { + return new ClientConfig + { + Id = "qbit-test", + Name = "QBit Test", + Type = DownloadClient.QBittorrent, + Enabled = true, + Url = "http://localhost:8080", + Username = "admin", + Password = "adminadmin" + }; + } + + public ClientConfig CreateTransmissionClientConfig() + { + return new ClientConfig + { + Id = "transmission-test", + Name = "Transmission Test", + Type = DownloadClient.Transmission, + Enabled = true, + Url = "http://localhost:9091", + Username = "admin", + Password = "adminadmin", + UrlBase = "transmission" + }; + } + + public ClientConfig CreateDelugeClientConfig() + { + return new ClientConfig + { + Id = "deluge-test", + Name = "Deluge Test", + Type = DownloadClient.Deluge, + Enabled = true, + Url = "http://localhost:8112", + Username = "admin", + Password = "deluge" + }; + } + + public void Dispose() + { + // Cleanup if needed + } +} diff --git a/code/Infrastructure.Tests/Http/DynamicHttpClientProviderTests.cs b/code/Infrastructure.Tests/Http/DynamicHttpClientProviderTests.cs new file mode 100644 index 00000000..e30e28d2 --- /dev/null +++ b/code/Infrastructure.Tests/Http/DynamicHttpClientProviderTests.cs @@ -0,0 +1,135 @@ +using System.Net; +using Common.Configuration.DownloadClient; +using Common.Enums; +using Infrastructure.Http; +using Shouldly; + +namespace Infrastructure.Tests.Http; + +public class DynamicHttpClientProviderTests : IClassFixture +{ + private readonly DynamicHttpClientProviderFixture _fixture; + + public DynamicHttpClientProviderTests(DynamicHttpClientProviderFixture fixture) + { + _fixture = fixture; + } + + [Fact] + public void CreateClient_WithQBitConfig_ShouldReturnConfiguredClient() + { + // Arrange + var sut = _fixture.CreateSut(); + var config = _fixture.CreateQBitClientConfig(); + + // Act + var httpClient = sut.CreateClient(config); + + // Assert + httpClient.ShouldNotBeNull(); + httpClient.BaseAddress.ShouldBe(new Uri(config.Url)); + VerifyDefaultHttpClientProperties(httpClient); + } + + [Fact] + public void CreateClient_WithTransmissionConfig_ShouldReturnConfiguredClient() + { + // Arrange + var sut = _fixture.CreateSut(); + var config = _fixture.CreateTransmissionClientConfig(); + + // Act + var httpClient = sut.CreateClient(config); + + // Assert + httpClient.ShouldNotBeNull(); + httpClient.BaseAddress.ShouldBe(new Uri(config.Url)); + VerifyDefaultHttpClientProperties(httpClient); + } + + [Fact] + public void CreateClient_WithDelugeConfig_ShouldReturnConfiguredClient() + { + // Arrange + var sut = _fixture.CreateSut(); + var config = _fixture.CreateDelugeClientConfig(); + + // Act + var httpClient = sut.CreateClient(config); + + // Assert + httpClient.ShouldNotBeNull(); + httpClient.BaseAddress.ShouldBe(new Uri(config.Url)); + + // Deluge client should have additional properties configured + VerifyDelugeHttpClientProperties(httpClient); + } + + [Fact] + public void CreateClient_WithSameConfig_ShouldReturnUniqueInstances() + { + // Arrange + var sut = _fixture.CreateSut(); + var config = _fixture.CreateQBitClientConfig(); + + // Act + var firstClient = sut.CreateClient(config); + var secondClient = sut.CreateClient(config); + + // Assert + firstClient.ShouldNotBeNull(); + secondClient.ShouldNotBeNull(); + firstClient.ShouldNotBeSameAs(secondClient); // Should be different instances + } + + [Fact] + public void CreateClient_WithCustomCertificateValidation_ShouldConfigureHandler() + { + // Arrange + var sut = _fixture.CreateSut(); + var config = _fixture.CreateQBitClientConfig(); + config.IgnoreSslErrors = true; + + // Act + var httpClient = sut.CreateClient(config); + + // Assert + httpClient.ShouldNotBeNull(); + + // Since we can't directly access the handler settings after creation, + // we verify the behavior is working by checking if the client can be created properly + httpClient.BaseAddress.ShouldBe(new Uri(config.Url)); + } + + [Fact] + public void CreateClient_WithTimeout_ShouldConfigureTimeout() + { + // Arrange + var sut = _fixture.CreateSut(); + var config = _fixture.CreateQBitClientConfig(); + TimeSpan expectedTimeout = TimeSpan.FromSeconds(30); + + // Act + var httpClient = sut.CreateClient(config); + + // Assert + httpClient.Timeout.ShouldBe(expectedTimeout); + } + + private void VerifyDefaultHttpClientProperties(HttpClient httpClient) + { + // Check common properties that should be set for all clients + httpClient.Timeout.ShouldBe(TimeSpan.FromSeconds(30)); + httpClient.DefaultRequestHeaders.ShouldNotBeNull(); + } + + private void VerifyDelugeHttpClientProperties(HttpClient httpClient) + { + // Verify Deluge-specific HTTP client configurations + VerifyDefaultHttpClientProperties(httpClient); + + // Using reflection to access the handler is tricky and potentially brittle + // Instead, we focus on verifying the client itself is properly configured + httpClient.BaseAddress.ShouldNotBeNull(); + } +} diff --git a/code/Infrastructure.Tests/Verticals/DownloadClient/Factory/DownloadClientFactoryFixture.cs b/code/Infrastructure.Tests/Verticals/DownloadClient/Factory/DownloadClientFactoryFixture.cs new file mode 100644 index 00000000..dcab94a0 --- /dev/null +++ b/code/Infrastructure.Tests/Verticals/DownloadClient/Factory/DownloadClientFactoryFixture.cs @@ -0,0 +1,109 @@ +using Common.Configuration.DownloadClient; +using Common.Enums; +using Infrastructure.Configuration; +using Infrastructure.Http; +using Infrastructure.Verticals.DownloadClient; +using Infrastructure.Verticals.DownloadClient.Deluge; +using Infrastructure.Verticals.DownloadClient.Factory; +using Infrastructure.Verticals.DownloadClient.QBittorrent; +using Infrastructure.Verticals.DownloadClient.Transmission; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using NSubstitute; + +namespace Infrastructure.Tests.Verticals.DownloadClient.Factory; + +public class DownloadClientFactoryFixture : IDisposable +{ + public ILogger Logger { get; } + public IConfigManager ConfigManager { get; } + public IServiceProvider ServiceProvider { get; } + public DownloadClientConfig DownloadClientConfig { get; } + + public DownloadClientFactoryFixture() + { + Logger = Substitute.For>(); + ConfigManager = Substitute.For(); + + // Set up test download client config + DownloadClientConfig = new DownloadClientConfig + { + Clients = new List + { + new() + { + Id = "qbit1", + Name = "Test QBittorrent", + Type = DownloadClient.QBittorrent, + Enabled = true, + Url = "http://localhost:8080", + Username = "admin", + Password = "adminadmin" + }, + new() + { + Id = "transmission1", + Name = "Test Transmission", + Type = DownloadClient.Transmission, + Enabled = true, + Url = "http://localhost:9091", + Username = "admin", + Password = "adminadmin" + }, + new() + { + Id = "deluge1", + Name = "Test Deluge", + Type = DownloadClient.Deluge, + Enabled = true, + Url = "http://localhost:8112", + Username = "admin", + Password = "adminadmin" + }, + new() + { + Id = "disabled1", + Name = "Disabled Client", + Type = DownloadClient.QBittorrent, + Enabled = false, + Url = "http://localhost:5555" + } + } + }; + + // Configure the ConfigManager to return our test config + ConfigManager.GetDownloadClientConfigAsync().Returns(Task.FromResult(DownloadClientConfig)); + + // Set up mock services + var serviceCollection = new ServiceCollection(); + + // Mock the services that will be resolved + var qbitService = Substitute.For(); + var transmissionService = Substitute.For(); + var delugeService = Substitute.For(); + var httpClientProvider = Substitute.For(); + + // Register our mock services in the service collection + serviceCollection.AddSingleton(qbitService); + serviceCollection.AddSingleton(transmissionService); + serviceCollection.AddSingleton(delugeService); + serviceCollection.AddSingleton(httpClientProvider); + + // Build the service provider + ServiceProvider = serviceCollection.BuildServiceProvider(); + } + + public DownloadClientFactory CreateSut() + { + return new DownloadClientFactory( + Logger, + ConfigManager, + ServiceProvider + ); + } + + public void Dispose() + { + // Clean up if needed + } +} diff --git a/code/Infrastructure.Tests/Verticals/DownloadClient/Factory/DownloadClientFactoryTests.cs b/code/Infrastructure.Tests/Verticals/DownloadClient/Factory/DownloadClientFactoryTests.cs new file mode 100644 index 00000000..714a1b88 --- /dev/null +++ b/code/Infrastructure.Tests/Verticals/DownloadClient/Factory/DownloadClientFactoryTests.cs @@ -0,0 +1,201 @@ +using System.Collections.Concurrent; +using Common.Configuration.DownloadClient; +using Common.Enums; +using Infrastructure.Verticals.DownloadClient; +using Infrastructure.Verticals.DownloadClient.Deluge; +using Infrastructure.Verticals.DownloadClient.Factory; +using Infrastructure.Verticals.DownloadClient.QBittorrent; +using Infrastructure.Verticals.DownloadClient.Transmission; +using NSubstitute; +using Shouldly; + +namespace Infrastructure.Tests.Verticals.DownloadClient.Factory; + +public class DownloadClientFactoryTests : IClassFixture +{ + private readonly DownloadClientFactoryFixture _fixture; + + public DownloadClientFactoryTests(DownloadClientFactoryFixture fixture) + { + _fixture = fixture; + } + + [Fact] + public async Task Initialize_ShouldCreateClientsForEnabledConfigurations() + { + // Arrange + var sut = _fixture.CreateSut(); + + // Act + await sut.Initialize(); + + // Assert + var clients = GetPrivateClientsCollection(sut); + clients.Count.ShouldBe(3); // Only enabled clients should be initialized + clients.Keys.ShouldContain("qbit1"); + clients.Keys.ShouldContain("transmission1"); + clients.Keys.ShouldContain("deluge1"); + clients.Keys.ShouldNotContain("disabled1"); + } + + [Fact] + public async Task GetClient_WithExistingId_ShouldReturnExistingClient() + { + // Arrange + var sut = _fixture.CreateSut(); + await sut.Initialize(); + + // Get an initial reference to the client + var firstClient = sut.GetClient("qbit1"); + firstClient.ShouldNotBeNull(); + + // Act + var secondClient = sut.GetClient("qbit1"); + + // Assert + secondClient.ShouldBeSameAs(firstClient); // Should return the same instance + } + + [Fact] + public async Task GetClient_WithNonExistingId_ShouldCreateNewClient() + { + // Arrange + var sut = _fixture.CreateSut(); + await sut.Initialize(); + + // Clear the internal clients collection to simulate a client that hasn't been created yet + var clients = GetPrivateClientsCollection(sut); + clients.Clear(); + + // Act + var client = sut.GetClient("qbit1"); + + // Assert + client.ShouldNotBeNull(); + client.ShouldBeOfType(); + clients.Count.ShouldBe(1); + } + + [Fact] + public void GetClient_WithEmptyId_ShouldThrowArgumentException() + { + // Arrange + var sut = _fixture.CreateSut(); + + // Act & Assert + Should.Throw(() => sut.GetClient(string.Empty)); + } + + [Fact] + public async Task GetClient_WithInvalidId_ShouldThrowKeyNotFoundException() + { + // Arrange + var sut = _fixture.CreateSut(); + await sut.Initialize(); + + // Act & Assert + Should.Throw(() => sut.GetClient("invalid-client-id")); + } + + [Fact] + public async Task GetAllClients_ShouldReturnAllEnabledClients() + { + // Arrange + var sut = _fixture.CreateSut(); + await sut.Initialize(); + + // Act + var clients = sut.GetAllClients(); + + // Assert + clients.Count().ShouldBe(3); + clients.Select(c => c.GetClientId()).ShouldContain("qbit1"); + clients.Select(c => c.GetClientId()).ShouldContain("transmission1"); + clients.Select(c => c.GetClientId()).ShouldContain("deluge1"); + } + + [Fact] + public async Task GetClientByType_ShouldReturnCorrectClientType() + { + // Arrange + var sut = _fixture.CreateSut(); + await sut.Initialize(); + + // Act + var qbitClients = sut.GetClientsByType(DownloadClient.QBittorrent); + var transmissionClients = sut.GetClientsByType(DownloadClient.Transmission); + var delugeClients = sut.GetClientsByType(DownloadClient.Deluge); + + // Assert + qbitClients.Count().ShouldBe(1); + qbitClients.First().ShouldBeOfType(); + + transmissionClients.Count().ShouldBe(1); + transmissionClients.First().ShouldBeOfType(); + + delugeClients.Count().ShouldBe(1); + delugeClients.First().ShouldBeOfType(); + } + + [Fact] + public async Task CreateClient_WithValidConfig_ShouldReturnInitializedClient() + { + // Arrange + var sut = _fixture.CreateSut(); + var config = _fixture.DownloadClientConfig.Clients.First(c => c.Type == DownloadClient.QBittorrent); + + // Act + var client = await sut.CreateClient(config.Id); + + // Assert + client.ShouldNotBeNull(); + client.GetClientId().ShouldBe(config.Id); + } + + [Fact] + public async Task RefreshClients_ShouldReinitializeAllClients() + { + // Arrange + var sut = _fixture.CreateSut(); + await sut.Initialize(); + + // Get initial collection of clients + var initialClients = sut.GetAllClients().ToList(); + + // Now modify the config to add a new client + var updatedConfig = new DownloadClientConfig + { + Clients = new List(_fixture.DownloadClientConfig.Clients) + }; + + // Add a new client + updatedConfig.Clients.Add(new ClientConfig + { + Id = "new-client", + Name = "New QBittorrent", + Type = DownloadClient.QBittorrent, + Enabled = true, + Url = "http://localhost:9999" + }); + + // Update the mock ConfigManager to return the updated config + _fixture.ConfigManager.GetDownloadClientConfigAsync().Returns(Task.FromResult(updatedConfig)); + + // Act + await sut.RefreshClients(); + var refreshedClients = sut.GetAllClients().ToList(); + + // Assert + refreshedClients.Count.ShouldBe(4); // Should have one more client now + refreshedClients.Select(c => c.GetClientId()).ShouldContain("new-client"); + } + + // Helper method to access the private _clients field using reflection + private ConcurrentDictionary GetPrivateClientsCollection(DownloadClientFactory factory) + { + var field = typeof(DownloadClientFactory).GetField("_clients", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + + return (ConcurrentDictionary)field!.GetValue(factory)!; + } +} diff --git a/code/Infrastructure/Health/ClientHealthChangedEventArgs.cs b/code/Infrastructure/Health/ClientHealthChangedEventArgs.cs new file mode 100644 index 00000000..1f944a7b --- /dev/null +++ b/code/Infrastructure/Health/ClientHealthChangedEventArgs.cs @@ -0,0 +1,51 @@ +namespace Infrastructure.Health; + +/// +/// Event arguments for client health changes +/// +public class ClientHealthChangedEventArgs : EventArgs +{ + /// + /// Gets the client ID + /// + public string ClientId { get; } + + /// + /// Gets the health status + /// + public HealthStatus Status { get; } + + /// + /// Gets a value indicating whether this is a transition to a healthy state + /// + public bool IsRecovered { get; } + + /// + /// Gets a value indicating whether this is a transition to an unhealthy state + /// + public bool IsDegraded { get; } + + /// + /// Initializes a new instance of the class + /// + /// The client ID + /// The current health status + /// The previous health status, if any + public ClientHealthChangedEventArgs(string clientId, HealthStatus status, HealthStatus? previousStatus) + { + ClientId = clientId; + Status = status; + + // Determine if this is a state transition + if (previousStatus != null) + { + IsRecovered = !previousStatus.IsHealthy && status.IsHealthy; + IsDegraded = previousStatus.IsHealthy && !status.IsHealthy; + } + else + { + IsRecovered = false; + IsDegraded = !status.IsHealthy; + } + } +} diff --git a/code/Infrastructure/Health/HealthCheckBackgroundService.cs b/code/Infrastructure/Health/HealthCheckBackgroundService.cs new file mode 100644 index 00000000..57fd69fc --- /dev/null +++ b/code/Infrastructure/Health/HealthCheckBackgroundService.cs @@ -0,0 +1,89 @@ +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Infrastructure.Health; + +/// +/// Background service that periodically checks the health of all download clients +/// +public class HealthCheckBackgroundService : BackgroundService +{ + private readonly ILogger _logger; + private readonly IHealthCheckService _healthCheckService; + private readonly TimeSpan _checkInterval; + + /// + /// Initializes a new instance of the class + /// + /// The logger + /// The health check service + public HealthCheckBackgroundService( + ILogger logger, + IHealthCheckService healthCheckService) + { + _logger = logger; + _healthCheckService = healthCheckService; + + // Check health every 1 minute by default + _checkInterval = TimeSpan.FromMinutes(1); + } + + /// + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("Health check background service started"); + + try + { + while (!stoppingToken.IsCancellationRequested) + { + _logger.LogDebug("Performing periodic health check for all clients"); + + try + { + // Check health of all clients + var results = await _healthCheckService.CheckAllClientsHealthAsync(); + + // Log summary + var healthyCount = results.Count(r => r.Value.IsHealthy); + var unhealthyCount = results.Count - healthyCount; + + _logger.LogInformation( + "Health check completed. {healthyCount} healthy, {unhealthyCount} unhealthy clients", + healthyCount, + unhealthyCount); + + // Log detailed information for unhealthy clients + foreach (var result in results.Where(r => !r.Value.IsHealthy)) + { + _logger.LogWarning( + "Client {clientId} ({clientName}) is unhealthy: {errorMessage}", + result.Key, + result.Value.ClientName, + result.Value.ErrorMessage); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error performing periodic health check"); + } + + // Wait for the next check interval + await Task.Delay(_checkInterval, stoppingToken); + } + } + catch (OperationCanceledException) + { + // Normal shutdown, no need to log error + _logger.LogInformation("Health check background service stopping"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error in health check background service"); + } + finally + { + _logger.LogInformation("Health check background service stopped"); + } + } +} diff --git a/code/Infrastructure/Health/HealthCheckService.cs b/code/Infrastructure/Health/HealthCheckService.cs new file mode 100644 index 00000000..18ca7d5e --- /dev/null +++ b/code/Infrastructure/Health/HealthCheckService.cs @@ -0,0 +1,224 @@ +using Common.Configuration.DownloadClient; +using Infrastructure.Configuration; +using Infrastructure.Verticals.DownloadClient; +using Infrastructure.Verticals.DownloadClient.Factory; +using Microsoft.Extensions.Logging; + +namespace Infrastructure.Health; + +/// +/// Service for checking the health of download clients +/// +public class HealthCheckService : IHealthCheckService +{ + private readonly ILogger _logger; + private readonly IConfigManager _configManager; + private readonly IDownloadClientFactory _clientFactory; + private readonly Dictionary _healthStatuses = new(); + private readonly object _lockObject = new(); + + /// + /// Occurs when a client's health status changes + /// + public event EventHandler? ClientHealthChanged; + + /// + /// Initializes a new instance of the class + /// + /// The logger + /// The configuration manager + /// The download client factory + public HealthCheckService( + ILogger logger, + IConfigManager configManager, + IDownloadClientFactory clientFactory) + { + _logger = logger; + _configManager = configManager; + _clientFactory = clientFactory; + } + + /// + public async Task CheckClientHealthAsync(string clientId) + { + _logger.LogDebug("Checking health for client {clientId}", clientId); + + try + { + // Get the client configuration + var config = await GetClientConfigAsync(clientId); + if (config == null) + { + _logger.LogWarning("Client {clientId} not found in configuration", clientId); + var notFoundStatus = new HealthStatus + { + ClientId = clientId, + IsHealthy = false, + LastChecked = DateTime.UtcNow, + ErrorMessage = "Client not found in configuration" + }; + + UpdateHealthStatus(notFoundStatus); + return notFoundStatus; + } + + // Get the client instance + var client = _clientFactory.GetClient(clientId); + + // Measure response time + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + try + { + // Execute the login to check connectivity + await client.LoginAsync(); + + stopwatch.Stop(); + + // Create health status object + var status = new HealthStatus + { + ClientId = clientId, + ClientName = config.Name, + ClientType = config.Type, + IsHealthy = true, + LastChecked = DateTime.UtcNow, + ResponseTime = stopwatch.Elapsed + }; + + UpdateHealthStatus(status); + return status; + } + catch (Exception ex) + { + stopwatch.Stop(); + + _logger.LogWarning(ex, "Health check failed for client {clientId}", clientId); + + var status = new HealthStatus + { + ClientId = clientId, + ClientName = config.Name, + ClientType = config.Type, + IsHealthy = false, + LastChecked = DateTime.UtcNow, + ErrorMessage = $"Connection failed: {ex.Message}", + ResponseTime = stopwatch.Elapsed + }; + + UpdateHealthStatus(status); + return status; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error performing health check for client {clientId}", clientId); + + var status = new HealthStatus + { + ClientId = clientId, + IsHealthy = false, + LastChecked = DateTime.UtcNow, + ErrorMessage = $"Error: {ex.Message}" + }; + + UpdateHealthStatus(status); + return status; + } + } + + /// + public async Task> CheckAllClientsHealthAsync() + { + _logger.LogDebug("Checking health for all enabled clients"); + + try + { + // Get all enabled client configurations + var config = await _configManager.GetDownloadClientConfigAsync(); + if (config == null) + { + _logger.LogWarning("Download client configuration not found"); + return new Dictionary(); + } + + var enabledClients = config.GetEnabledClients(); + var results = new Dictionary(); + + // Check health of each enabled client + foreach (var clientConfig in enabledClients) + { + var status = await CheckClientHealthAsync(clientConfig.Id); + results[clientConfig.Id] = status; + } + + return results; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error checking health for all clients"); + return new Dictionary(); + } + } + + /// + public HealthStatus? GetClientHealth(string clientId) + { + lock (_lockObject) + { + return _healthStatuses.TryGetValue(clientId, out var status) ? status : null; + } + } + + /// + public IDictionary GetAllClientHealth() + { + lock (_lockObject) + { + return new Dictionary(_healthStatuses); + } + } + + private async Task GetClientConfigAsync(string clientId) + { + var config = await _configManager.GetDownloadClientConfigAsync(); + return config?.GetClientConfig(clientId); + } + + private void UpdateHealthStatus(HealthStatus newStatus) + { + HealthStatus? previousStatus = null; + + lock (_lockObject) + { + // Get previous status for comparison + _healthStatuses.TryGetValue(newStatus.ClientId, out previousStatus); + + // Update status + _healthStatuses[newStatus.ClientId] = newStatus; + } + + // Determine if there's a significant change + bool isStateChange = previousStatus == null || + previousStatus.IsHealthy != newStatus.IsHealthy; + + // Raise event if there's a significant change + if (isStateChange) + { + _logger.LogInformation( + "Client {clientId} health changed: {status}", + newStatus.ClientId, + newStatus.IsHealthy ? "Healthy" : "Unhealthy"); + + OnClientHealthChanged(new ClientHealthChangedEventArgs( + newStatus.ClientId, + newStatus, + previousStatus)); + } + } + + private void OnClientHealthChanged(ClientHealthChangedEventArgs e) + { + ClientHealthChanged?.Invoke(this, e); + } +} diff --git a/code/Infrastructure/Health/HealthStatus.cs b/code/Infrastructure/Health/HealthStatus.cs new file mode 100644 index 00000000..ae8f8857 --- /dev/null +++ b/code/Infrastructure/Health/HealthStatus.cs @@ -0,0 +1,42 @@ +namespace Infrastructure.Health; + +/// +/// Represents the health status of a client +/// +public class HealthStatus +{ + /// + /// Gets or sets whether the client is healthy + /// + public bool IsHealthy { get; set; } + + /// + /// Gets or sets the time when the client was last checked + /// + public DateTime LastChecked { get; set; } + + /// + /// Gets or sets the error message if the client is not healthy + /// + public string? ErrorMessage { get; set; } + + /// + /// Gets or sets the response time of the last health check + /// + public TimeSpan ResponseTime { get; set; } + + /// + /// Gets or sets the client ID + /// + public string ClientId { get; set; } = string.Empty; + + /// + /// Gets or sets the client name + /// + public string ClientName { get; set; } = string.Empty; + + /// + /// Gets or sets the client type + /// + public Common.Enums.DownloadClient ClientType { get; set; } +} diff --git a/code/Infrastructure/Health/HealthStatusBroadcaster.cs b/code/Infrastructure/Health/HealthStatusBroadcaster.cs new file mode 100644 index 00000000..cb5a310e --- /dev/null +++ b/code/Infrastructure/Health/HealthStatusBroadcaster.cs @@ -0,0 +1,82 @@ +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Infrastructure.Health; + +/// +/// Service that broadcasts health status changes via SignalR +/// +public class HealthStatusBroadcaster : IHostedService +{ + private readonly ILogger _logger; + private readonly IHealthCheckService _healthCheckService; + private readonly IHubContext _hubContext; + + /// + /// Initializes a new instance of the class + /// + /// The logger + /// The health check service + /// The SignalR hub context + public HealthStatusBroadcaster( + ILogger logger, + IHealthCheckService healthCheckService, + IHubContext hubContext) + { + _logger = logger; + _healthCheckService = healthCheckService; + _hubContext = hubContext; + } + + /// + public Task StartAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("Health status broadcaster starting"); + + // Subscribe to health status change events + _healthCheckService.ClientHealthChanged += OnClientHealthChanged; + + return Task.CompletedTask; + } + + /// + public Task StopAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("Health status broadcaster stopping"); + + // Unsubscribe from health status change events + _healthCheckService.ClientHealthChanged -= OnClientHealthChanged; + + return Task.CompletedTask; + } + + private async void OnClientHealthChanged(object? sender, ClientHealthChangedEventArgs e) + { + try + { + _logger.LogDebug("Broadcasting health status change for client {clientId}", e.ClientId); + + // Broadcast to all clients + await _hubContext.Clients.All.SendAsync("HealthStatusChanged", e.Status); + + // Send degradation messages + if (e.IsDegraded) + { + _logger.LogWarning("Client {clientId} health degraded", e.ClientId); + await _hubContext.Clients.All.SendAsync("ClientDegraded", e.Status); + } + + // Send recovery messages + if (e.IsRecovered) + { + _logger.LogInformation("Client {clientId} health recovered", e.ClientId); + await _hubContext.Clients.All.SendAsync("ClientRecovered", e.Status); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error broadcasting health status change for client {clientId}", e.ClientId); + } + } +} diff --git a/code/Infrastructure/Health/HealthStatusHub.cs b/code/Infrastructure/Health/HealthStatusHub.cs new file mode 100644 index 00000000..752e5be0 --- /dev/null +++ b/code/Infrastructure/Health/HealthStatusHub.cs @@ -0,0 +1,38 @@ +using Microsoft.AspNetCore.SignalR; + +namespace Infrastructure.Health; + +/// +/// SignalR hub for broadcasting health status updates +/// +public class HealthStatusHub : Hub +{ + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class + /// + /// The logger + public HealthStatusHub(ILogger logger) + { + _logger = logger; + } + + /// + /// Called when a client connects to the hub + /// + public override async Task OnConnectedAsync() + { + _logger.LogInformation("Client connected: {connectionId}", Context.ConnectionId); + await base.OnConnectedAsync(); + } + + /// + /// Called when a client disconnects from the hub + /// + public override async Task OnDisconnectedAsync(Exception? exception) + { + _logger.LogInformation("Client disconnected: {connectionId}", Context.ConnectionId); + await base.OnDisconnectedAsync(exception); + } +} diff --git a/code/Infrastructure/Health/IHealthCheckService.cs b/code/Infrastructure/Health/IHealthCheckService.cs new file mode 100644 index 00000000..1b334385 --- /dev/null +++ b/code/Infrastructure/Health/IHealthCheckService.cs @@ -0,0 +1,38 @@ +namespace Infrastructure.Health; + +/// +/// Service for checking the health of download clients +/// +public interface IHealthCheckService +{ + /// + /// Occurs when a client's health status changes + /// + event EventHandler ClientHealthChanged; + + /// + /// Checks the health of a specific client + /// + /// The client ID to check + /// The health status of the client + Task CheckClientHealthAsync(string clientId); + + /// + /// Checks the health of all enabled clients + /// + /// A dictionary of client IDs to health statuses + Task> CheckAllClientsHealthAsync(); + + /// + /// Gets the current health status of a client + /// + /// The client ID + /// The current health status, or null if the client hasn't been checked + HealthStatus? GetClientHealth(string clientId); + + /// + /// Gets the current health status of all clients that have been checked + /// + /// A dictionary of client IDs to health statuses + IDictionary GetAllClientHealth(); +}