diff --git a/code/Executable/Controllers/ApiDocumentationController.cs b/code/Executable/Controllers/ApiDocumentationController.cs new file mode 100644 index 00000000..e4e3fb97 --- /dev/null +++ b/code/Executable/Controllers/ApiDocumentationController.cs @@ -0,0 +1,14 @@ +using Microsoft.AspNetCore.Mvc; + +namespace Executable.Controllers; + +[ApiController] +[Route("")] +public class ApiDocumentationController : ControllerBase +{ + [HttpGet] + public IActionResult RedirectToSwagger() + { + return Redirect("/swagger"); + } +} diff --git a/code/Executable/Controllers/ConfigurationController.cs b/code/Executable/Controllers/ConfigurationController.cs new file mode 100644 index 00000000..8232a091 --- /dev/null +++ b/code/Executable/Controllers/ConfigurationController.cs @@ -0,0 +1,154 @@ +using Common.Configuration.ContentBlocker; +using Common.Configuration.DownloadCleaner; +using Common.Configuration.QueueCleaner; +using Infrastructure.Services; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; +using System.Text.Json; + +namespace Executable.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class ConfigurationController : ControllerBase +{ + private readonly ILogger _logger; + private readonly IConfiguration _configuration; + private readonly IOptionsMonitor _queueCleanerConfig; + private readonly IOptionsMonitor _contentBlockerConfig; + private readonly IOptionsMonitor _downloadCleanerConfig; + private readonly IConfigurationService _configService; + + public ConfigurationController( + ILogger logger, + IConfiguration configuration, + IOptionsMonitor queueCleanerConfig, + IOptionsMonitor contentBlockerConfig, + IOptionsMonitor downloadCleanerConfig, + IConfigurationService configService) + { + _logger = logger; + _configuration = configuration; + _queueCleanerConfig = queueCleanerConfig; + _contentBlockerConfig = contentBlockerConfig; + _downloadCleanerConfig = downloadCleanerConfig; + _configService = configService; + } + + [HttpGet("queuecleaner")] + public IActionResult GetQueueCleanerConfig() + { + try + { + return Ok(_queueCleanerConfig.CurrentValue); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error retrieving QueueCleaner configuration"); + return StatusCode(500, "An error occurred while retrieving QueueCleaner configuration"); + } + } + + [HttpGet("contentblocker")] + public IActionResult GetContentBlockerConfig() + { + try + { + return Ok(_contentBlockerConfig.CurrentValue); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error retrieving ContentBlocker configuration"); + return StatusCode(500, "An error occurred while retrieving ContentBlocker configuration"); + } + } + + [HttpGet("downloadcleaner")] + public IActionResult GetDownloadCleanerConfig() + { + try + { + return Ok(_downloadCleanerConfig.CurrentValue); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error retrieving DownloadCleaner configuration"); + return StatusCode(500, "An error occurred while retrieving DownloadCleaner configuration"); + } + } + + [HttpPut("queuecleaner")] + public async Task UpdateQueueCleanerConfig([FromBody] QueueCleanerConfig config) + { + try + { + // Validate the configuration + config.Validate(); + + // Persist the configuration + var result = await _configService.UpdateConfigurationAsync(QueueCleanerConfig.SectionName, config); + if (!result) + { + return StatusCode(500, "Failed to save QueueCleaner configuration"); + } + + _logger.LogInformation("QueueCleaner configuration updated successfully"); + return Ok(new { Message = "QueueCleaner configuration updated successfully" }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error updating QueueCleaner configuration"); + return StatusCode(500, "An error occurred while updating QueueCleaner configuration"); + } + } + + [HttpPut("contentblocker")] + public async Task UpdateContentBlockerConfig([FromBody] ContentBlockerConfig config) + { + try + { + // Validate the configuration + config.Validate(); + + // Persist the configuration + var result = await _configService.UpdateConfigurationAsync(ContentBlockerConfig.SectionName, config); + if (!result) + { + return StatusCode(500, "Failed to save ContentBlocker configuration"); + } + + _logger.LogInformation("ContentBlocker configuration updated successfully"); + return Ok(new { Message = "ContentBlocker configuration updated successfully" }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error updating ContentBlocker configuration"); + return StatusCode(500, "An error occurred while updating ContentBlocker configuration"); + } + } + + [HttpPut("downloadcleaner")] + public async Task UpdateDownloadCleanerConfig([FromBody] DownloadCleanerConfig config) + { + try + { + // Validate the configuration + config.Validate(); + + // Persist the configuration + var result = await _configService.UpdateConfigurationAsync(DownloadCleanerConfig.SectionName, config); + if (!result) + { + return StatusCode(500, "Failed to save DownloadCleaner configuration"); + } + + _logger.LogInformation("DownloadCleaner configuration updated successfully"); + return Ok(new { Message = "DownloadCleaner configuration updated successfully" }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error updating DownloadCleaner configuration"); + return StatusCode(500, "An error occurred while updating DownloadCleaner configuration"); + } + } +} diff --git a/code/Executable/Controllers/JobsController.cs b/code/Executable/Controllers/JobsController.cs new file mode 100644 index 00000000..04d9f0eb --- /dev/null +++ b/code/Executable/Controllers/JobsController.cs @@ -0,0 +1,153 @@ +using Infrastructure.Models; +using Infrastructure.Services; +using Microsoft.AspNetCore.Mvc; + +namespace Executable.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class JobsController : ControllerBase +{ + private readonly IJobManagementService _jobManagementService; + private readonly ILogger _logger; + + public JobsController(IJobManagementService jobManagementService, ILogger logger) + { + _jobManagementService = jobManagementService; + _logger = logger; + } + + [HttpGet] + public async Task GetAllJobs() + { + try + { + var result = await _jobManagementService.GetAllJobs(); + return Ok(result); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting all jobs"); + return StatusCode(500, "An error occurred while retrieving jobs"); + } + } + + [HttpGet("{jobName}")] + public async Task GetJob(string jobName) + { + try + { + var jobInfo = await _jobManagementService.GetJob(jobName); + if (jobInfo.Status == "Not Found") + { + return NotFound($"Job '{jobName}' not found"); + } + return Ok(jobInfo); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting job {jobName}", jobName); + return StatusCode(500, $"An error occurred while retrieving job '{jobName}'"); + } + } + + [HttpPost("{jobName}/start")] + public async Task StartJob(string jobName, [FromQuery] string cronExpression = null) + { + try + { + var result = await _jobManagementService.StartJob(jobName, cronExpression); + if (!result) + { + return BadRequest($"Failed to start job '{jobName}'"); + } + return Ok(new { Message = $"Job '{jobName}' started successfully" }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error starting job {jobName}", jobName); + return StatusCode(500, $"An error occurred while starting job '{jobName}'"); + } + } + + [HttpPost("{jobName}/stop")] + public async Task StopJob(string jobName) + { + try + { + var result = await _jobManagementService.StopJob(jobName); + if (!result) + { + return BadRequest($"Failed to stop job '{jobName}'"); + } + return Ok(new { Message = $"Job '{jobName}' stopped successfully" }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error stopping job {jobName}", jobName); + return StatusCode(500, $"An error occurred while stopping job '{jobName}'"); + } + } + + [HttpPost("{jobName}/pause")] + public async Task PauseJob(string jobName) + { + try + { + var result = await _jobManagementService.PauseJob(jobName); + if (!result) + { + return BadRequest($"Failed to pause job '{jobName}'"); + } + return Ok(new { Message = $"Job '{jobName}' paused successfully" }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error pausing job {jobName}", jobName); + return StatusCode(500, $"An error occurred while pausing job '{jobName}'"); + } + } + + [HttpPost("{jobName}/resume")] + public async Task ResumeJob(string jobName) + { + try + { + var result = await _jobManagementService.ResumeJob(jobName); + if (!result) + { + return BadRequest($"Failed to resume job '{jobName}'"); + } + return Ok(new { Message = $"Job '{jobName}' resumed successfully" }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error resuming job {jobName}", jobName); + return StatusCode(500, $"An error occurred while resuming job '{jobName}'"); + } + } + + [HttpPut("{jobName}/schedule")] + public async Task UpdateJobSchedule(string jobName, [FromQuery] string cronExpression) + { + if (string.IsNullOrEmpty(cronExpression)) + { + return BadRequest("Cron expression is required"); + } + + try + { + var result = await _jobManagementService.UpdateJobSchedule(jobName, cronExpression); + if (!result) + { + return BadRequest($"Failed to update schedule for job '{jobName}'"); + } + return Ok(new { Message = $"Job '{jobName}' schedule updated successfully" }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error updating job {jobName} schedule", jobName); + return StatusCode(500, $"An error occurred while updating schedule for job '{jobName}'"); + } + } +} diff --git a/code/Executable/Controllers/StatusController.cs b/code/Executable/Controllers/StatusController.cs new file mode 100644 index 00000000..c9c3facb --- /dev/null +++ b/code/Executable/Controllers/StatusController.cs @@ -0,0 +1,257 @@ +using Common.Configuration.Arr; +using Common.Configuration.DownloadClient; +using Infrastructure.Verticals.Arr; +using Infrastructure.Verticals.DownloadClient; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; +using System.Diagnostics; + +namespace Executable.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class StatusController : ControllerBase +{ + private readonly ILogger _logger; + private readonly IOptionsMonitor _downloadClientConfig; + private readonly IOptionsMonitor _sonarrConfig; + private readonly IOptionsMonitor _radarrConfig; + private readonly IOptionsMonitor _lidarrConfig; + private readonly DownloadServiceFactory _downloadServiceFactory; + private readonly ArrClientFactory _arrClientFactory; + + public StatusController( + ILogger logger, + IOptionsMonitor downloadClientConfig, + IOptionsMonitor sonarrConfig, + IOptionsMonitor radarrConfig, + IOptionsMonitor lidarrConfig, + DownloadServiceFactory downloadServiceFactory, + ArrClientFactory arrClientFactory) + { + _logger = logger; + _downloadClientConfig = downloadClientConfig; + _sonarrConfig = sonarrConfig; + _radarrConfig = radarrConfig; + _lidarrConfig = lidarrConfig; + _downloadServiceFactory = downloadServiceFactory; + _arrClientFactory = arrClientFactory; + } + + [HttpGet] + public IActionResult GetSystemStatus() + { + try + { + var process = Process.GetCurrentProcess(); + + var status = new + { + Application = new + { + Version = GetType().Assembly.GetName().Version?.ToString() ?? "Unknown", + StartTime = process.StartTime, + UpTime = DateTime.Now - process.StartTime, + MemoryUsageMB = Math.Round(process.WorkingSet64 / 1024.0 / 1024.0, 2), + ProcessorTime = process.TotalProcessorTime + }, + DownloadClient = new + { + Type = _downloadClientConfig.CurrentValue.DownloadClient.ToString(), + IsConfigured = _downloadClientConfig.CurrentValue.DownloadClient != Common.Enums.DownloadClient.None && + _downloadClientConfig.CurrentValue.DownloadClient != Common.Enums.DownloadClient.Disabled + }, + MediaManagers = new + { + Sonarr = new + { + IsEnabled = _sonarrConfig.CurrentValue.Enabled, + InstanceCount = _sonarrConfig.CurrentValue.Instances?.Count ?? 0 + }, + Radarr = new + { + IsEnabled = _radarrConfig.CurrentValue.Enabled, + InstanceCount = _radarrConfig.CurrentValue.Instances?.Count ?? 0 + }, + Lidarr = new + { + IsEnabled = _lidarrConfig.CurrentValue.Enabled, + InstanceCount = _lidarrConfig.CurrentValue.Instances?.Count ?? 0 + } + } + }; + + return Ok(status); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error retrieving system status"); + return StatusCode(500, "An error occurred while retrieving system status"); + } + } + + [HttpGet("download-client")] + public async Task GetDownloadClientStatus() + { + try + { + if (_downloadClientConfig.CurrentValue.DownloadClient == Common.Enums.DownloadClient.None || + _downloadClientConfig.CurrentValue.DownloadClient == Common.Enums.DownloadClient.Disabled) + { + return NotFound("No download client is configured"); + } + + var downloadService = _downloadServiceFactory.CreateDownloadClient(); + + try + { + await downloadService.LoginAsync(); + + // Basic status info that should be safe for any download client + var status = new + { + IsConnected = true, + ClientType = _downloadClientConfig.CurrentValue.DownloadClient.ToString(), + Message = "Successfully connected to download client" + }; + + return Ok(status); + } + catch (Exception ex) + { + return StatusCode(503, new + { + IsConnected = false, + ClientType = _downloadClientConfig.CurrentValue.DownloadClient.ToString(), + Message = $"Failed to connect to download client: {ex.Message}" + }); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error retrieving download client status"); + return StatusCode(500, "An error occurred while retrieving download client status"); + } + } + + [HttpGet("media-managers")] + public async Task GetMediaManagersStatus() + { + try + { + var status = new Dictionary(); + + // Check Sonarr instances + if (_sonarrConfig.CurrentValue.Enabled && _sonarrConfig.CurrentValue.Instances?.Count > 0) + { + var sonarrStatus = new List(); + + foreach (var instance in _sonarrConfig.CurrentValue.Instances) + { + try + { + var sonarrClient = _arrClientFactory.GetClient(Domain.Enums.InstanceType.Sonarr); + await sonarrClient.TestConnectionAsync(instance); + + sonarrStatus.Add(new + { + Name = instance.Name, + Url = instance.Url, + IsConnected = true, + Message = "Successfully connected" + }); + } + catch (Exception ex) + { + sonarrStatus.Add(new + { + Name = instance.Name, + Url = instance.Url, + IsConnected = false, + Message = $"Connection failed: {ex.Message}" + }); + } + } + + status["Sonarr"] = sonarrStatus; + } + + // Check Radarr instances + if (_radarrConfig.CurrentValue.Enabled && _radarrConfig.CurrentValue.Instances?.Count > 0) + { + var radarrStatus = new List(); + + foreach (var instance in _radarrConfig.CurrentValue.Instances) + { + try + { + var radarrClient = _arrClientFactory.GetClient(Domain.Enums.InstanceType.Radarr); + await radarrClient.TestConnectionAsync(instance); + + radarrStatus.Add(new + { + Name = instance.Name, + Url = instance.Url, + IsConnected = true, + Message = "Successfully connected" + }); + } + catch (Exception ex) + { + radarrStatus.Add(new + { + Name = instance.Name, + Url = instance.Url, + IsConnected = false, + Message = $"Connection failed: {ex.Message}" + }); + } + } + + status["Radarr"] = radarrStatus; + } + + // Check Lidarr instances + if (_lidarrConfig.CurrentValue.Enabled && _lidarrConfig.CurrentValue.Instances?.Count > 0) + { + var lidarrStatus = new List(); + + foreach (var instance in _lidarrConfig.CurrentValue.Instances) + { + try + { + var lidarrClient = _arrClientFactory.GetClient(Domain.Enums.InstanceType.Lidarr); + await lidarrClient.TestConnectionAsync(instance); + + lidarrStatus.Add(new + { + Name = instance.Name, + Url = instance.Url, + IsConnected = true, + Message = "Successfully connected" + }); + } + catch (Exception ex) + { + lidarrStatus.Add(new + { + Name = instance.Name, + Url = instance.Url, + IsConnected = false, + Message = $"Connection failed: {ex.Message}" + }); + } + } + + status["Lidarr"] = lidarrStatus; + } + + return Ok(status); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error retrieving media managers status"); + return StatusCode(500, "An error occurred while retrieving media managers status"); + } + } +} diff --git a/code/Executable/DependencyInjection/ApiDI.cs b/code/Executable/DependencyInjection/ApiDI.cs new file mode 100644 index 00000000..821c3b1b --- /dev/null +++ b/code/Executable/DependencyInjection/ApiDI.cs @@ -0,0 +1,51 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.OpenApi.Models; + +namespace Executable.DependencyInjection; + +public static class ApiDI +{ + public static IServiceCollection AddApiServices(this IServiceCollection services) + { + // Add API-specific services + services.AddControllers(); + services.AddEndpointsApiExplorer(); + services.AddSwaggerGen(options => + { + options.SwaggerDoc("v1", new OpenApiInfo + { + Title = "Cleanuperr API", + Version = "v1", + Description = "API for managing media downloads and cleanups", + Contact = new OpenApiContact + { + Name = "Cleanuperr Team" + } + }); + }); + + return services; + } + + public static WebApplication ConfigureApi(this WebApplication app) + { + // Configure middleware pipeline for API + if (app.Environment.IsDevelopment()) + { + app.UseSwagger(); + app.UseSwaggerUI(options => + { + options.SwaggerEndpoint("/swagger/v1/swagger.json", "Cleanuperr API v1"); + options.RoutePrefix = "swagger"; + options.DocumentTitle = "Cleanuperr API Documentation"; + }); + } + + app.UseHttpsRedirection(); + app.UseAuthorization(); + app.MapControllers(); + + return app; + } +} diff --git a/code/Executable/DependencyInjection/ServicesDI.cs b/code/Executable/DependencyInjection/ServicesDI.cs index e2e557de..232ab1c2 100644 --- a/code/Executable/DependencyInjection/ServicesDI.cs +++ b/code/Executable/DependencyInjection/ServicesDI.cs @@ -1,4 +1,4 @@ -using Common.Configuration.ContentBlocker; +using Common.Configuration.ContentBlocker; using Common.Configuration.DownloadCleaner; using Common.Configuration.QueueCleaner; using Infrastructure.Interceptors; @@ -16,6 +16,7 @@ using Infrastructure.Verticals.DownloadRemover.Interfaces; using Infrastructure.Verticals.Files; using Infrastructure.Verticals.ItemStriker; using Infrastructure.Verticals.QueueCleaner; +using Infrastructure.Services; namespace Executable.DependencyInjection; @@ -23,6 +24,10 @@ public static class ServicesDI { public static IServiceCollection AddServices(this IServiceCollection services) => services + // API services + .AddSingleton() + .AddSingleton() + // Core services .AddTransient() .AddTransient() .AddTransient() diff --git a/code/Executable/Executable.csproj b/code/Executable/Executable.csproj index 79c6b329..b0ec404c 100644 --- a/code/Executable/Executable.csproj +++ b/code/Executable/Executable.csproj @@ -1,4 +1,4 @@ - + cleanuperr @@ -22,6 +22,9 @@ + + + diff --git a/code/Executable/Program.cs b/code/Executable/Program.cs index 5623667f..c440cc0d 100644 --- a/code/Executable/Program.cs +++ b/code/Executable/Program.cs @@ -1,12 +1,21 @@ using Executable; using Executable.DependencyInjection; -var builder = Host.CreateApplicationBuilder(args); +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container +builder.Services + .AddInfrastructure(builder.Configuration) + .AddApiServices(); -builder.Services.AddInfrastructure(builder.Configuration); builder.Logging.AddLogging(builder.Configuration); -var host = builder.Build(); -host.Init(); +var app = builder.Build(); -host.Run(); \ No newline at end of file +// Configure the HTTP request pipeline +app.ConfigureApi(); + +// Initialize the host +((IHost)app).Init(); + +app.Run(); \ No newline at end of file diff --git a/code/Infrastructure/Models/JobInfo.cs b/code/Infrastructure/Models/JobInfo.cs new file mode 100644 index 00000000..5a7791d7 --- /dev/null +++ b/code/Infrastructure/Models/JobInfo.cs @@ -0,0 +1,11 @@ +namespace Infrastructure.Models; + +public class JobInfo +{ + public string Name { get; set; } = string.Empty; + public string Status { get; set; } = string.Empty; + public string Schedule { get; set; } = string.Empty; + public DateTime? NextRunTime { get; set; } + public DateTime? PreviousRunTime { get; set; } + public string JobType { get; set; } = string.Empty; +} diff --git a/code/Infrastructure/Services/ConfigurationService.cs b/code/Infrastructure/Services/ConfigurationService.cs new file mode 100644 index 00000000..517c0379 --- /dev/null +++ b/code/Infrastructure/Services/ConfigurationService.cs @@ -0,0 +1,138 @@ +using Common.Configuration; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace Infrastructure.Services; + +public interface IConfigurationService +{ + Task UpdateConfigurationAsync(string sectionName, T configSection) where T : class, IConfig; + Task GetConfigurationAsync(string sectionName) where T : class, IConfig; + Task RefreshConfigurationAsync(); +} + +public class ConfigurationService : IConfigurationService +{ + private readonly ILogger _logger; + private readonly IConfiguration _configuration; + private readonly string _configFilePath; + + public ConfigurationService( + ILogger logger, + IConfiguration configuration, + IWebHostEnvironment environment) + { + _logger = logger; + _configuration = configuration; + + // Find primary configuration file + var currentDirectory = environment.ContentRootPath; + _configFilePath = Path.Combine(currentDirectory, "appsettings.json"); + + if (!File.Exists(_configFilePath)) + { + _logger.LogWarning("Configuration file not found at: {path}", _configFilePath); + _configFilePath = Path.Combine(currentDirectory, "appsettings.Development.json"); + + if (!File.Exists(_configFilePath)) + { + _logger.LogError("No configuration file found"); + throw new FileNotFoundException("Configuration file not found"); + } + } + + _logger.LogInformation("Using configuration file: {path}", _configFilePath); + } + + public async Task UpdateConfigurationAsync(string sectionName, T configSection) where T : class, IConfig + { + try + { + // Read existing configuration + var json = await File.ReadAllTextAsync(_configFilePath); + var jsonObject = JsonNode.Parse(json)?.AsObject() + ?? throw new InvalidOperationException("Failed to parse configuration file"); + + // Create JsonObject from config section + var configJson = JsonSerializer.Serialize(configSection); + var configObject = JsonNode.Parse(configJson)?.AsObject() + ?? throw new InvalidOperationException("Failed to serialize configuration"); + + // Update or add the section + jsonObject[sectionName] = configObject; + + // Save back to file + var options = new JsonSerializerOptions { WriteIndented = true }; + var updatedJson = jsonObject.ToJsonString(options); + + // Create backup + var backupPath = $"{_configFilePath}.bak"; + await File.WriteAllTextAsync(backupPath, json); + + // Write updated configuration + await File.WriteAllTextAsync(_configFilePath, updatedJson); + + // Refresh configuration + await RefreshConfigurationAsync(); + + _logger.LogInformation("Configuration section {section} updated successfully", sectionName); + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error updating configuration section {section}", sectionName); + return false; + } + } + + public async Task GetConfigurationAsync(string sectionName) where T : class, IConfig + { + try + { + var json = await File.ReadAllTextAsync(_configFilePath); + var jsonObject = JsonNode.Parse(json)?.AsObject(); + + if (jsonObject == null || !jsonObject.ContainsKey(sectionName)) + { + _logger.LogWarning("Section {section} not found in configuration", sectionName); + return null; + } + + var sectionObject = jsonObject[sectionName]?.ToJsonString(); + if (sectionObject == null) + { + return null; + } + + return JsonSerializer.Deserialize(sectionObject); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error retrieving configuration section {section}", sectionName); + return null; + } + } + + public Task RefreshConfigurationAsync() + { + try + { + if (_configuration is IConfigurationRoot configRoot) + { + configRoot.Reload(); + _logger.LogInformation("Configuration reloaded"); + return Task.FromResult(true); + } + + _logger.LogWarning("Unable to reload configuration: IConfigurationRoot not available"); + return Task.FromResult(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error reloading configuration"); + return Task.FromResult(false); + } + } +} diff --git a/code/Infrastructure/Services/JobManagementService.cs b/code/Infrastructure/Services/JobManagementService.cs new file mode 100644 index 00000000..71a9c032 --- /dev/null +++ b/code/Infrastructure/Services/JobManagementService.cs @@ -0,0 +1,339 @@ +using Common.Configuration; +using Infrastructure.Models; +using Microsoft.Extensions.Logging; +using Quartz; +using System.Collections.Concurrent; + +namespace Infrastructure.Services; + +public interface IJobManagementService +{ + Task StartJob(string jobName, string cronExpression = null); + Task StopJob(string jobName); + Task PauseJob(string jobName); + Task ResumeJob(string jobName); + Task> GetAllJobs(); + Task GetJob(string jobName); + Task UpdateJobSchedule(string jobName, string cronExpression); +} + +public class JobManagementService : IJobManagementService +{ + private readonly ILogger _logger; + private readonly ISchedulerFactory _schedulerFactory; + private readonly ConcurrentDictionary _jobKeys = new(); + + public JobManagementService(ILogger logger, ISchedulerFactory schedulerFactory) + { + _logger = logger; + _schedulerFactory = schedulerFactory; + } + + public async Task StartJob(string jobName, string cronExpression = null) + { + try + { + var scheduler = await _schedulerFactory.GetScheduler(); + var jobKey = new JobKey(jobName); + + // Check if job exists + if (!await scheduler.CheckExists(jobKey)) + { + _logger.LogError("Job {jobName} does not exist", jobName); + return false; + } + + // Store the job key for later use + _jobKeys.TryAdd(jobName, jobKey); + + // If cron expression is provided, update the trigger + if (!string.IsNullOrEmpty(cronExpression)) + { + var triggerKey = new TriggerKey($"{jobName}Trigger"); + var existingTrigger = await scheduler.GetTrigger(triggerKey); + + if (existingTrigger != null) + { + var newTrigger = TriggerBuilder.Create() + .WithIdentity(triggerKey) + .ForJob(jobKey) + .WithCronSchedule(cronExpression) + .Build(); + + await scheduler.RescheduleJob(triggerKey, newTrigger); + } + else + { + var trigger = TriggerBuilder.Create() + .WithIdentity(triggerKey) + .ForJob(jobKey) + .WithCronSchedule(cronExpression) + .Build(); + + await scheduler.ScheduleJob(trigger); + } + } + else + { + // If no trigger exists, create a simple one-time trigger + var triggers = await scheduler.GetTriggersOfJob(jobKey); + if (!triggers.Any()) + { + var trigger = TriggerBuilder.Create() + .WithIdentity($"{jobName}Trigger") + .ForJob(jobKey) + .StartNow() + .Build(); + + await scheduler.ScheduleJob(trigger); + } + } + + // Resume the job if it's paused + await scheduler.ResumeJob(jobKey); + _logger.LogInformation("Job {jobName} started successfully", jobName); + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error starting job {jobName}", jobName); + return false; + } + } + + public async Task StopJob(string jobName) + { + try + { + var scheduler = await _schedulerFactory.GetScheduler(); + var jobKey = new JobKey(jobName); + + if (!await scheduler.CheckExists(jobKey)) + { + _logger.LogError("Job {jobName} does not exist", jobName); + return false; + } + + // Unschedule all triggers for this job + var triggers = await scheduler.GetTriggersOfJob(jobKey); + foreach (var trigger in triggers) + { + await scheduler.UnscheduleJob(trigger.Key); + } + + _logger.LogInformation("Job {jobName} stopped successfully", jobName); + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error stopping job {jobName}", jobName); + return false; + } + } + + public async Task PauseJob(string jobName) + { + try + { + var scheduler = await _schedulerFactory.GetScheduler(); + var jobKey = new JobKey(jobName); + + if (!await scheduler.CheckExists(jobKey)) + { + _logger.LogError("Job {jobName} does not exist", jobName); + return false; + } + + await scheduler.PauseJob(jobKey); + _logger.LogInformation("Job {jobName} paused successfully", jobName); + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error pausing job {jobName}", jobName); + return false; + } + } + + public async Task ResumeJob(string jobName) + { + try + { + var scheduler = await _schedulerFactory.GetScheduler(); + var jobKey = new JobKey(jobName); + + if (!await scheduler.CheckExists(jobKey)) + { + _logger.LogError("Job {jobName} does not exist", jobName); + return false; + } + + await scheduler.ResumeJob(jobKey); + _logger.LogInformation("Job {jobName} resumed successfully", jobName); + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error resuming job {jobName}", jobName); + return false; + } + } + + public async Task> GetAllJobs() + { + try + { + var scheduler = await _schedulerFactory.GetScheduler(); + var result = new List(); + + var jobGroups = await scheduler.GetJobGroupNames(); + foreach (var group in jobGroups) + { + var jobKeys = await scheduler.GetJobKeys(GroupMatcher.GroupEquals(group)); + foreach (var jobKey in jobKeys) + { + var jobDetail = await scheduler.GetJobDetail(jobKey); + var triggers = await scheduler.GetTriggersOfJob(jobKey); + var jobInfo = new JobInfo + { + Name = jobKey.Name, + JobType = jobDetail.JobType.Name, + Status = "Not Scheduled" + }; + + if (triggers.Any()) + { + var trigger = triggers.First(); + var triggerState = await scheduler.GetTriggerState(trigger.Key); + + jobInfo.Status = triggerState switch + { + TriggerState.Normal => "Running", + TriggerState.Paused => "Paused", + TriggerState.Complete => "Complete", + TriggerState.Error => "Error", + TriggerState.Blocked => "Blocked", + TriggerState.None => "None", + _ => "Unknown" + }; + + if (trigger is ICronTrigger cronTrigger) + { + jobInfo.Schedule = cronTrigger.CronExpressionString; + } + + jobInfo.NextRunTime = trigger.GetNextFireTimeUtc()?.LocalDateTime; + jobInfo.PreviousRunTime = trigger.GetPreviousFireTimeUtc()?.LocalDateTime; + } + + result.Add(jobInfo); + } + } + + return result; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting all jobs"); + return new List(); + } + } + + public async Task GetJob(string jobName) + { + try + { + var scheduler = await _schedulerFactory.GetScheduler(); + var jobKey = new JobKey(jobName); + + if (!await scheduler.CheckExists(jobKey)) + { + _logger.LogError("Job {jobName} does not exist", jobName); + return new JobInfo { Name = jobName, Status = "Not Found" }; + } + + var jobDetail = await scheduler.GetJobDetail(jobKey); + var triggers = await scheduler.GetTriggersOfJob(jobKey); + + var jobInfo = new JobInfo + { + Name = jobName, + JobType = jobDetail.JobType.Name, + Status = "Not Scheduled" + }; + + if (triggers.Any()) + { + var trigger = triggers.First(); + var state = await scheduler.GetTriggerState(trigger.Key); + + jobInfo.Status = state switch + { + TriggerState.Normal => "Running", + TriggerState.Paused => "Paused", + TriggerState.Complete => "Complete", + TriggerState.Error => "Error", + TriggerState.Blocked => "Blocked", + TriggerState.None => "None", + _ => "Unknown" + }; + + if (trigger is ICronTrigger cronTrigger) + { + jobInfo.Schedule = cronTrigger.CronExpressionString; + } + + jobInfo.NextRunTime = trigger.GetNextFireTimeUtc()?.LocalDateTime; + jobInfo.PreviousRunTime = trigger.GetPreviousFireTimeUtc()?.LocalDateTime; + } + + return jobInfo; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting job {jobName}", jobName); + return new JobInfo { Name = jobName, Status = "Error" }; + } + } + + public async Task UpdateJobSchedule(string jobName, string cronExpression) + { + try + { + var scheduler = await _schedulerFactory.GetScheduler(); + var jobKey = new JobKey(jobName); + + if (!await scheduler.CheckExists(jobKey)) + { + _logger.LogError("Job {jobName} does not exist", jobName); + return false; + } + + var triggerKey = new TriggerKey($"{jobName}Trigger"); + var existingTrigger = await scheduler.GetTrigger(triggerKey); + + var newTrigger = TriggerBuilder.Create() + .WithIdentity(triggerKey) + .ForJob(jobKey) + .WithCronSchedule(cronExpression) + .Build(); + + if (existingTrigger != null) + { + await scheduler.RescheduleJob(triggerKey, newTrigger); + } + else + { + await scheduler.ScheduleJob(newTrigger); + } + + _logger.LogInformation("Job {jobName} schedule updated successfully to {cronExpression}", jobName, cronExpression); + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error updating job {jobName} schedule", jobName); + return false; + } + } +}