feat: Implementazione completa sistema schedulazione con intervalli personalizzati
- Aggiunto supporto schedulazione con intervalli flessibili (secondi/minuti/ore/giorni/settimane/mesi) - Esteso modello ProfileSchedule con campi IntervalValue e IntervalUnit - Ottimizzato ScheduledJobService per controlli ogni 30s con esecuzione parallela - Implementata interfaccia UI completa con anteprima real-time in italiano - Aggiunta migrazione database AddIntervalSchedulingFields - Implementati metodi calcolo NextExecutionTime per intervalli - Aggiunta gestione tracking anti-duplicati e cleanup automatico - Creata documentazione completa (6 file, 2500+ righe) Modifiche tecniche: - ProfileSchedule.cs: Nuovi campi e metodi CalculateNextInterval/GetScheduleDescription - ScheduledJobService.cs: Ridotto check interval a 30s, aggiunto parallel processing - ProfileScheduleService.cs: Supporto calcolo intervalli in UpdateNextExecutionTimeAsync - Scheduling.razor: Aggiunta sezione UI per configurazione intervalli - Scheduling.razor.cs: Implementato GetIntervalPreview() e gestione stato campi
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
using CredentialManager.Services;
|
||||
using Data_Coupler.Services;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Data_Coupler.BackgroundServices;
|
||||
|
||||
/// <summary>
|
||||
/// Servizio di background per l'esecuzione automatica delle schedulazioni
|
||||
/// </summary>
|
||||
public class ScheduleExecutorService : BackgroundService
|
||||
{
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly ILogger<ScheduleExecutorService> _logger;
|
||||
private readonly TimeSpan _checkInterval = TimeSpan.FromMinutes(1); // Controlla ogni minuto
|
||||
|
||||
public ScheduleExecutorService(IServiceProvider serviceProvider, ILogger<ScheduleExecutorService> logger)
|
||||
{
|
||||
_serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("ScheduleExecutorService avviato. Controllo schedulazioni ogni {Interval} minuti.",
|
||||
_checkInterval.TotalMinutes);
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await CheckAndExecuteSchedules();
|
||||
await Task.Delay(_checkInterval, stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.LogInformation("ScheduleExecutorService arrestato.");
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Errore nel controllo schedulazioni");
|
||||
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken); // Attendi 5 minuti prima di riprovare
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CheckAndExecuteSchedules()
|
||||
{
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
var scheduleService = scope.ServiceProvider.GetRequiredService<IProfileScheduleService>();
|
||||
var dataTransferService = scope.ServiceProvider.GetRequiredService<IDataTransferService>();
|
||||
|
||||
try
|
||||
{
|
||||
var pendingSchedules = await scheduleService.GetPendingExecutionsAsync();
|
||||
|
||||
if (pendingSchedules.Any())
|
||||
{
|
||||
_logger.LogInformation("Trovate {Count} schedulazioni in attesa di esecuzione", pendingSchedules.Count);
|
||||
}
|
||||
|
||||
foreach (var schedule in pendingSchedules)
|
||||
{
|
||||
try
|
||||
{
|
||||
await ExecuteSchedule(schedule, scheduleService, dataTransferService);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Errore nell'esecuzione della schedulazione {ScheduleName} (ID: {ScheduleId})",
|
||||
schedule.Name, schedule.Id);
|
||||
|
||||
// Aggiorna lo status dell'esecuzione fallita
|
||||
await scheduleService.UpdateExecutionStatusAsync(schedule.Id, "failed",
|
||||
$"Errore nell'esecuzione: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Errore nel recupero delle schedulazioni in attesa");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ExecuteSchedule(CredentialManager.Models.ProfileSchedule schedule,
|
||||
IProfileScheduleService scheduleService,
|
||||
IDataTransferService dataTransferService)
|
||||
{
|
||||
_logger.LogInformation("Esecuzione schedulazione {ScheduleName} (ID: {ScheduleId}) iniziata",
|
||||
schedule.Name, schedule.Id);
|
||||
|
||||
// Aggiorna lo status a "running"
|
||||
await scheduleService.UpdateExecutionStatusAsync(schedule.Id, "running",
|
||||
"Esecuzione automatica avviata");
|
||||
|
||||
try
|
||||
{
|
||||
if (schedule.Profile == null)
|
||||
{
|
||||
throw new InvalidOperationException("Profilo associato alla schedulazione non trovato");
|
||||
}
|
||||
|
||||
// Esegui il trasferimento dati
|
||||
var result = await dataTransferService.ExecuteProfileAsync(schedule.Profile);
|
||||
|
||||
// Aggiorna lo status con il risultato
|
||||
var status = result.IsSuccess ? "success" : "failed";
|
||||
var message = result.IsSuccess
|
||||
? $"Esecuzione completata con successo in {result.Duration.TotalSeconds:F1} secondi. " +
|
||||
$"{result.RecordsProcessed} record elaborati."
|
||||
: $"Esecuzione fallita: {result.ErrorMessage}";
|
||||
|
||||
await scheduleService.UpdateExecutionStatusAsync(schedule.Id, status, message, result.RecordsProcessed);
|
||||
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
_logger.LogInformation("Schedulazione {ScheduleName} completata con successo. " +
|
||||
"Record processati: {RecordsProcessed}, Durata: {Duration}s",
|
||||
schedule.Name, result.RecordsProcessed, result.Duration.TotalSeconds);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Schedulazione {ScheduleName} fallita: {ErrorMessage}",
|
||||
schedule.Name, result.ErrorMessage);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Errore durante l'esecuzione della schedulazione {ScheduleName}", schedule.Name);
|
||||
|
||||
await scheduleService.UpdateExecutionStatusAsync(schedule.Id, "failed",
|
||||
$"Errore nell'esecuzione: {ex.Message}");
|
||||
|
||||
throw; // Re-throw per permettere la gestione a livello superiore
|
||||
}
|
||||
}
|
||||
|
||||
public override async Task StopAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("ScheduleExecutorService in fase di arresto...");
|
||||
await base.StopAsync(stoppingToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
using CredentialManager.Services;
|
||||
using CredentialManager.Models;
|
||||
using Data_Coupler.Services;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Data_Coupler.BackgroundServices;
|
||||
|
||||
/// <summary>
|
||||
/// Background service per l'esecuzione automatica delle schedulazioni
|
||||
/// </summary>
|
||||
public class ScheduledJobService : BackgroundService
|
||||
{
|
||||
private readonly IServiceScopeFactory _serviceScopeFactory;
|
||||
private readonly ILogger<ScheduledJobService> _logger;
|
||||
private TimeSpan _checkInterval = TimeSpan.FromSeconds(30); // Controlla ogni 30 secondi per supportare intervalli brevi
|
||||
private readonly Dictionary<int, DateTime> _runningSchedules = new(); // Tiene traccia delle schedulazioni in esecuzione
|
||||
|
||||
public ScheduledJobService(
|
||||
IServiceScopeFactory serviceScopeFactory,
|
||||
ILogger<ScheduledJobService> logger)
|
||||
{
|
||||
_serviceScopeFactory = serviceScopeFactory ?? throw new ArgumentNullException(nameof(serviceScopeFactory));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("ScheduledJobService avviato");
|
||||
|
||||
// Attendi alcuni secondi prima di iniziare per permettere la completa inizializzazione dell'app
|
||||
try
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.LogInformation("ScheduledJobService cancellato durante l'inizializzazione");
|
||||
return;
|
||||
}
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await CheckAndExecutePendingSchedules(stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.LogInformation("ScheduledJobService cancellato");
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Errore durante il controllo delle schedulazioni");
|
||||
|
||||
// In caso di errore grave, attendi di più prima del prossimo tentativo
|
||||
try
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(_checkInterval, stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("ScheduledJobService arrestato");
|
||||
}
|
||||
|
||||
private async Task CheckAndExecutePendingSchedules(CancellationToken cancellationToken)
|
||||
{
|
||||
using var scope = _serviceScopeFactory.CreateScope();
|
||||
var scheduleService = scope.ServiceProvider.GetRequiredService<IProfileScheduleService>();
|
||||
var dataTransferService = scope.ServiceProvider.GetRequiredService<IDataTransferService>();
|
||||
|
||||
try
|
||||
{
|
||||
// Ottieni le schedulazioni che devono essere eseguite
|
||||
var pendingSchedules = await scheduleService.GetPendingExecutionsAsync();
|
||||
|
||||
if (!pendingSchedules.Any())
|
||||
{
|
||||
_logger.LogTrace("Nessuna schedulazione in sospeso trovata");
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Trovate {Count} schedulazioni da eseguire", pendingSchedules.Count);
|
||||
|
||||
// Pulisci le schedulazioni completate dal tracking
|
||||
CleanupRunningSchedules();
|
||||
|
||||
foreach (var schedule in pendingSchedules)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
break;
|
||||
|
||||
// Verifica se la schedulazione è già in esecuzione
|
||||
if (IsScheduleRunning(schedule.Id))
|
||||
{
|
||||
_logger.LogDebug("Schedulazione {ScheduleId} già in esecuzione, salto", schedule.Id);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Esegui la schedulazione in modo asincrono senza attendere
|
||||
// Questo permette di eseguire più schedulazioni in parallelo
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await ExecuteScheduleAsync(schedule, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Errore nell'esecuzione asincrona della schedulazione {ScheduleId}", schedule.Id);
|
||||
}
|
||||
}, cancellationToken);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Errore durante il controllo delle schedulazioni pendenti");
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsScheduleRunning(int scheduleId)
|
||||
{
|
||||
lock (_runningSchedules)
|
||||
{
|
||||
return _runningSchedules.ContainsKey(scheduleId);
|
||||
}
|
||||
}
|
||||
|
||||
private void MarkScheduleAsRunning(int scheduleId)
|
||||
{
|
||||
lock (_runningSchedules)
|
||||
{
|
||||
_runningSchedules[scheduleId] = DateTime.Now;
|
||||
}
|
||||
}
|
||||
|
||||
private void MarkScheduleAsCompleted(int scheduleId)
|
||||
{
|
||||
lock (_runningSchedules)
|
||||
{
|
||||
_runningSchedules.Remove(scheduleId);
|
||||
}
|
||||
}
|
||||
|
||||
private void CleanupRunningSchedules()
|
||||
{
|
||||
lock (_runningSchedules)
|
||||
{
|
||||
var timeout = DateTime.Now.AddHours(-1); // Se una schedulazione è "running" da più di 1 ora, considerala bloccata
|
||||
var staleSchedules = _runningSchedules.Where(x => x.Value < timeout).Select(x => x.Key).ToList();
|
||||
|
||||
foreach (var scheduleId in staleSchedules)
|
||||
{
|
||||
_logger.LogWarning("Rimozione schedulazione {ScheduleId} da tracking (timeout esecuzione)", scheduleId);
|
||||
_runningSchedules.Remove(scheduleId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ExecuteScheduleAsync(
|
||||
ProfileSchedule schedule,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ScheduleExecutionHistory? executionHistory = null;
|
||||
|
||||
// Marca la schedulazione come in esecuzione
|
||||
MarkScheduleAsRunning(schedule.Id);
|
||||
|
||||
try
|
||||
{
|
||||
// Crea un nuovo scope per questa esecuzione
|
||||
using var scope = _serviceScopeFactory.CreateScope();
|
||||
var scheduleService = scope.ServiceProvider.GetRequiredService<IProfileScheduleService>();
|
||||
var dataTransferService = scope.ServiceProvider.GetRequiredService<IDataTransferService>();
|
||||
|
||||
_logger.LogInformation("Esecuzione automatica schedulazione {ScheduleId} - {ScheduleName}",
|
||||
schedule.Id, schedule.Name);
|
||||
|
||||
// Controlla se la schedulazione è ancora valida per l'esecuzione
|
||||
if (!IsScheduleReadyForExecution(schedule))
|
||||
{
|
||||
_logger.LogDebug("Schedulazione {ScheduleId} non più pronta per l'esecuzione", schedule.Id);
|
||||
return;
|
||||
}
|
||||
|
||||
// Crea record nello storico
|
||||
executionHistory = new ScheduleExecutionHistory
|
||||
{
|
||||
ScheduleId = schedule.Id,
|
||||
ProfileId = schedule.ProfileId,
|
||||
ProfileName = schedule.Profile?.Name ?? "Unknown",
|
||||
StartTime = DateTime.Now,
|
||||
Status = "running",
|
||||
TriggerType = "automatic",
|
||||
TriggeredBy = "System",
|
||||
SourceType = schedule.Profile?.SourceType,
|
||||
DestinationType = schedule.Profile?.DestinationType,
|
||||
SourceInfo = schedule.SourceDatabaseOverride != null ? $"Database Override: {schedule.SourceDatabaseOverride}" : null,
|
||||
DestinationInfo = schedule.DestinationDatabaseOverride != null ? $"Database Override: {schedule.DestinationDatabaseOverride}" : null,
|
||||
Message = "Esecuzione automatica avviata"
|
||||
};
|
||||
|
||||
executionHistory = await scheduleService.CreateExecutionHistoryAsync(executionHistory);
|
||||
|
||||
// Aggiorna lo status della schedulazione
|
||||
await scheduleService.UpdateExecutionStatusAsync(schedule.Id, "running",
|
||||
"Esecuzione automatica avviata");
|
||||
|
||||
// Esegui il trasferimento dati
|
||||
if (schedule.Profile == null)
|
||||
{
|
||||
throw new InvalidOperationException($"Profilo non trovato per la schedulazione {schedule.Id}");
|
||||
}
|
||||
|
||||
var result = await dataTransferService.ExecuteProfileAsync(
|
||||
schedule.Profile,
|
||||
schedule.SourceDatabaseOverride,
|
||||
schedule.DestinationDatabaseOverride);
|
||||
|
||||
// Aggiorna lo storico con il risultato
|
||||
executionHistory.EndTime = DateTime.Now;
|
||||
executionHistory.Status = result.IsSuccess ? "success" : "failed";
|
||||
executionHistory.RecordsProcessed = result.RecordsProcessed;
|
||||
executionHistory.Message = result.IsSuccess
|
||||
? $"Esecuzione automatica completata con successo. {result.RecordsProcessed} record elaborati in {result.Duration.TotalSeconds:F2} secondi."
|
||||
: $"Esecuzione automatica fallita: {result.ErrorMessage}";
|
||||
|
||||
if (!result.IsSuccess)
|
||||
{
|
||||
executionHistory.ErrorDetails = string.Join(Environment.NewLine, result.ErrorDetails);
|
||||
}
|
||||
|
||||
// Aggiungi informazioni aggiuntive se disponibili
|
||||
if (result.AdditionalInfo.Any())
|
||||
{
|
||||
executionHistory.AdditionalInfo = System.Text.Json.JsonSerializer.Serialize(result.AdditionalInfo);
|
||||
}
|
||||
|
||||
await scheduleService.UpdateExecutionHistoryAsync(executionHistory);
|
||||
|
||||
// Aggiorna lo status della schedulazione
|
||||
var status = result.IsSuccess ? "success" : "failed";
|
||||
var message = result.IsSuccess
|
||||
? $"Esecuzione automatica completata con successo. {result.RecordsProcessed} record elaborati."
|
||||
: $"Esecuzione automatica fallita: {result.ErrorMessage}";
|
||||
|
||||
await scheduleService.UpdateExecutionStatusAsync(schedule.Id, status, message, result.RecordsProcessed);
|
||||
|
||||
// Aggiorna la prossima data di esecuzione
|
||||
await scheduleService.UpdateNextExecutionTimeAsync(schedule.Id);
|
||||
|
||||
_logger.LogInformation("Schedulazione {ScheduleId} eseguita con successo: {RecordsProcessed} record, durata {Duration}s",
|
||||
schedule.Id, result.RecordsProcessed, result.Duration.TotalSeconds);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Errore durante l'esecuzione automatica della schedulazione {ScheduleId}", schedule.Id);
|
||||
|
||||
// Crea un nuovo scope per gestire l'errore
|
||||
try
|
||||
{
|
||||
using var errorScope = _serviceScopeFactory.CreateScope();
|
||||
var scheduleService = errorScope.ServiceProvider.GetRequiredService<IProfileScheduleService>();
|
||||
|
||||
// Aggiorna lo storico in caso di eccezione
|
||||
if (executionHistory != null)
|
||||
{
|
||||
executionHistory.EndTime = DateTime.Now;
|
||||
executionHistory.Status = "failed";
|
||||
executionHistory.Message = $"Errore durante l'esecuzione automatica: {ex.Message}";
|
||||
executionHistory.ErrorDetails = ex.ToString();
|
||||
await scheduleService.UpdateExecutionHistoryAsync(executionHistory);
|
||||
}
|
||||
|
||||
await scheduleService.UpdateExecutionStatusAsync(schedule.Id, "failed",
|
||||
$"Errore durante l'esecuzione automatica: {ex.Message}");
|
||||
}
|
||||
catch (Exception innerEx)
|
||||
{
|
||||
_logger.LogError(innerEx, "Errore durante l'aggiornamento dello stato di errore per la schedulazione {ScheduleId}", schedule.Id);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Rimuovi la schedulazione dal tracking
|
||||
MarkScheduleAsCompleted(schedule.Id);
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsScheduleReadyForExecution(ProfileSchedule schedule)
|
||||
{
|
||||
// Verifica che la schedulazione sia attiva e abilitata
|
||||
if (!schedule.IsActive || !schedule.IsEnabled)
|
||||
return false;
|
||||
|
||||
// Verifica che ci sia una prossima esecuzione programmata
|
||||
if (!schedule.NextExecutionTime.HasValue)
|
||||
return false;
|
||||
|
||||
// Per schedulazioni a intervalli, usa una tolleranza più stretta
|
||||
var tolerance = schedule.ScheduleType == "interval"
|
||||
? TimeSpan.FromSeconds(30) // 30 secondi per intervalli
|
||||
: TimeSpan.FromMinutes(1); // 1 minuto per altre schedulazioni
|
||||
|
||||
var now = DateTime.Now;
|
||||
var nextExecution = schedule.NextExecutionTime.Value;
|
||||
|
||||
return nextExecution <= now.Add(tolerance);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,558 @@
|
||||
@using Data_Coupler.Services
|
||||
@using Data_Coupler.Models
|
||||
@inject IBackupService BackupService
|
||||
@inject IJSRuntime JSRuntime
|
||||
@inject ILogger<BackupTab> Logger
|
||||
|
||||
<div class="settings-section">
|
||||
<h4>
|
||||
<i class="fas fa-download text-primary me-2"></i>
|
||||
Backup e Ripristino Dati
|
||||
</h4>
|
||||
<p class="text-muted">
|
||||
Gestisci il backup e il ripristino di profili, credenziali, associazioni e schedule.
|
||||
I backup non includono password o API keys per motivi di sicurezza.
|
||||
</p>
|
||||
|
||||
<div class="row">
|
||||
<!-- Export Section -->
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0">
|
||||
<i class="fas fa-upload text-success me-2"></i>
|
||||
Esporta Backup
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="card-text">
|
||||
Crea un backup completo del sistema con tutti i dati configurati.
|
||||
</p>
|
||||
|
||||
<!-- Opzioni Export -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-bold">Componenti da includere:</label>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" @bind="exportOptions.IncludeProfiles" id="exportProfiles">
|
||||
<label class="form-check-label" for="exportProfiles">
|
||||
<i class="fas fa-user-cog me-1"></i> Profili Data Coupler
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" @bind="exportOptions.IncludeCredentials" id="exportCredentials">
|
||||
<label class="form-check-label" for="exportCredentials">
|
||||
<i class="fas fa-key me-1"></i> Credenziali (senza password)
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" @bind="exportOptions.IncludeKeyAssociations" id="exportAssociations">
|
||||
<label class="form-check-label" for="exportAssociations">
|
||||
<i class="fas fa-link me-1"></i> Associazioni Chiavi
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" @bind="exportOptions.IncludeProfileSchedules" id="exportSchedules">
|
||||
<label class="form-check-label" for="exportSchedules">
|
||||
<i class="fas fa-clock me-1"></i> Schedule Profili
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" @bind="exportOptions.IncludeOnlyActiveRecords" id="exportOnlyActive">
|
||||
<label class="form-check-label" for="exportOnlyActive">
|
||||
Solo record attivi
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="exportDescription" class="form-label">Descrizione (opzionale):</label>
|
||||
<input type="text" class="form-control" id="exportDescription" @bind="exportOptions.Description"
|
||||
placeholder="Backup settimanale, Configurazione produzione, etc.">
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="exportCreatedBy" class="form-label">Creato da:</label>
|
||||
<input type="text" class="form-control" id="exportCreatedBy" @bind="exportOptions.CreatedBy"
|
||||
placeholder="Nome utente o sistema">
|
||||
</div>
|
||||
|
||||
<button class="btn btn-success" @onclick="ExportBackup" disabled="@isExporting">
|
||||
@if (isExporting)
|
||||
{
|
||||
<div class="spinner-border spinner-border-sm me-2" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
<text>Esportazione...</text>
|
||||
}
|
||||
else
|
||||
{
|
||||
<i class="fas fa-download me-2"></i>
|
||||
<text>Crea Backup</text>
|
||||
}
|
||||
</button>
|
||||
|
||||
@if (lastExportResult != null)
|
||||
{
|
||||
<div class="mt-3">
|
||||
<div class="alert alert-@(lastExportResult.Success ? "success" : "danger") alert-sm">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<strong>@(lastExportResult.Success ? "Successo" : "Errore"):</strong>
|
||||
<div>@lastExportResult.Message</div>
|
||||
@if (lastExportResult.Success)
|
||||
{
|
||||
<small class="text-muted">
|
||||
Durata: @lastExportResult.Duration.TotalSeconds.ToString("F1")s |
|
||||
Record: @(lastExportResult.ProcessedCounts.Profiles + lastExportResult.ProcessedCounts.Credentials + lastExportResult.ProcessedCounts.KeyAssociations + lastExportResult.ProcessedCounts.ProfileSchedules)
|
||||
</small>
|
||||
}
|
||||
</div>
|
||||
<button type="button" class="btn-close btn-close-sm" @onclick="() => lastExportResult = null"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Import Section -->
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0">
|
||||
<i class="fas fa-download text-primary me-2"></i>
|
||||
Importa Backup
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="card-text">
|
||||
Ripristina i dati da un file di backup precedentemente creato.
|
||||
</p>
|
||||
|
||||
<!-- File Upload -->
|
||||
<div class="mb-3">
|
||||
<label for="backupFile" class="form-label fw-bold">Seleziona file backup:</label>
|
||||
<InputFile class="form-control" id="backupFile"
|
||||
accept=".json" OnChange="OnFileSelected" />
|
||||
<div class="form-text">
|
||||
Seleziona un file .json di backup di Data Coupler
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (selectedBackupInfo != null)
|
||||
{
|
||||
<div class="mb-3">
|
||||
<div class="card border-info">
|
||||
<div class="card-header bg-info text-white">
|
||||
<h6 class="mb-0">
|
||||
<i class="fas fa-info-circle me-2"></i>
|
||||
Informazioni Backup
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-6">
|
||||
<strong>Versione:</strong> @selectedBackupInfo.Metadata.Version<br>
|
||||
<strong>Creato:</strong> @selectedBackupInfo.Metadata.CreatedAt.ToString("dd/MM/yyyy HH:mm")<br>
|
||||
@if (!string.IsNullOrEmpty(selectedBackupInfo.Metadata.CreatedBy))
|
||||
{
|
||||
<strong>Da:</strong> @selectedBackupInfo.Metadata.CreatedBy<br>
|
||||
}
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<strong>Profili:</strong> @selectedBackupInfo.Profiles.Count<br>
|
||||
<strong>Credenziali:</strong> @selectedBackupInfo.Credentials.Count<br>
|
||||
<strong>Associazioni:</strong> @selectedBackupInfo.KeyAssociations.Count<br>
|
||||
<strong>Schedule:</strong> @selectedBackupInfo.ProfileSchedules.Count<br>
|
||||
</div>
|
||||
@if (!string.IsNullOrEmpty(selectedBackupInfo.Metadata.Description))
|
||||
{
|
||||
<div class="col-12 mt-2">
|
||||
<strong>Descrizione:</strong> @selectedBackupInfo.Metadata.Description
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Opzioni Import -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-bold">Componenti da ripristinare:</label>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" @bind="restoreOptions.RestoreProfiles" id="restoreProfiles">
|
||||
<label class="form-check-label" for="restoreProfiles">
|
||||
<i class="fas fa-user-cog me-1"></i> Profili (@selectedBackupInfo.Profiles.Count)
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" @bind="restoreOptions.RestoreCredentials" id="restoreCredentials">
|
||||
<label class="form-check-label" for="restoreCredentials">
|
||||
<i class="fas fa-key me-1"></i> Credenziali (@selectedBackupInfo.Credentials.Count)
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" @bind="restoreOptions.RestoreKeyAssociations" id="restoreAssociations">
|
||||
<label class="form-check-label" for="restoreAssociations">
|
||||
<i class="fas fa-link me-1"></i> Associazioni (@selectedBackupInfo.KeyAssociations.Count)
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" @bind="restoreOptions.RestoreProfileSchedules" id="restoreSchedules">
|
||||
<label class="form-check-label" for="restoreSchedules">
|
||||
<i class="fas fa-clock me-1"></i> Schedule (@selectedBackupInfo.ProfileSchedules.Count)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" @bind="restoreOptions.OverwriteExisting" id="overwriteExisting">
|
||||
<label class="form-check-label" for="overwriteExisting">
|
||||
<i class="fas fa-exclamation-triangle text-warning me-1"></i>
|
||||
Sovrascrivi dati esistenti
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" @bind="restoreOptions.CreateBackupBeforeRestore" id="createBackupBefore">
|
||||
<label class="form-check-label" for="createBackupBefore">
|
||||
<i class="fas fa-shield-alt text-info me-1"></i>
|
||||
Crea backup di sicurezza prima del ripristino
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="importedBy" class="form-label">Importato da:</label>
|
||||
<input type="text" class="form-control" id="importedBy" @bind="restoreOptions.ImportedBy"
|
||||
placeholder="Nome utente">
|
||||
</div>
|
||||
}
|
||||
|
||||
<button class="btn btn-primary" @onclick="ImportBackup"
|
||||
disabled="@(isImporting || selectedBackupInfo == null)">
|
||||
@if (isImporting)
|
||||
{
|
||||
<div class="spinner-border spinner-border-sm me-2" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
<text>Importazione...</text>
|
||||
}
|
||||
else
|
||||
{
|
||||
<i class="fas fa-upload me-2"></i>
|
||||
<text>Importa Backup</text>
|
||||
}
|
||||
</button>
|
||||
|
||||
@if (lastImportResult != null)
|
||||
{
|
||||
<div class="mt-3">
|
||||
<div class="alert alert-@(lastImportResult.Success ? "success" : "danger") alert-sm">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<strong>@(lastImportResult.Success ? "Successo" : "Errore"):</strong>
|
||||
<div>@lastImportResult.Message</div>
|
||||
@if (lastImportResult.Success)
|
||||
{
|
||||
<small class="text-muted">
|
||||
Durata: @lastImportResult.Duration.TotalSeconds.ToString("F1")s |
|
||||
Record importati: @(lastImportResult.ProcessedCounts.Profiles + lastImportResult.ProcessedCounts.Credentials + lastImportResult.ProcessedCounts.KeyAssociations + lastImportResult.ProcessedCounts.ProfileSchedules)
|
||||
</small>
|
||||
}
|
||||
@if (lastImportResult.Warnings.Any())
|
||||
{
|
||||
<div class="mt-2">
|
||||
<strong>Avvisi:</strong>
|
||||
<ul class="mb-0">
|
||||
@foreach (var warning in lastImportResult.Warnings)
|
||||
{
|
||||
<li><small>@warning</small></li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<button type="button" class="btn-close btn-close-sm" @onclick="() => lastImportResult = null"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Backup History Section -->
|
||||
<div class="row mt-4">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0">
|
||||
<i class="fas fa-history text-secondary me-2"></i>
|
||||
Backup Recenti
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<p class="text-muted mb-0">
|
||||
Elenco dei file di backup nella cartella Documenti/DataCoupler/Backups
|
||||
</p>
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="RefreshBackupList">
|
||||
<i class="fas fa-refresh me-1"></i>
|
||||
Aggiorna
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (recentBackups.Any())
|
||||
{
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Nome File</th>
|
||||
<th>Data Creazione</th>
|
||||
<th>Dimensione</th>
|
||||
<th>Azioni</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var backup in recentBackups.Take(10))
|
||||
{
|
||||
<tr>
|
||||
<td>
|
||||
<i class="fas fa-file-code text-primary me-2"></i>
|
||||
@backup.Name
|
||||
</td>
|
||||
<td>
|
||||
<small>@backup.CreationTime.ToString("dd/MM/yyyy HH:mm")</small>
|
||||
</td>
|
||||
<td>
|
||||
<small>@FormatFileSize(backup.Length)</small>
|
||||
</td>
|
||||
<td>
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button class="btn btn-outline-primary"
|
||||
@onclick="() => DownloadBackup(backup.FullName)"
|
||||
title="Scarica">
|
||||
<i class="fas fa-download"></i>
|
||||
</button>
|
||||
<button class="btn btn-outline-info"
|
||||
@onclick='() => OnShowToast.InvokeAsync(("Funzione non disponibile", "info"))'
|
||||
title="Carica per import">
|
||||
<i class="fas fa-upload"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="text-center py-4">
|
||||
<i class="fas fa-folder-open text-muted fa-3x mb-3"></i>
|
||||
<p class="text-muted">Nessun backup trovato</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public EventCallback<(string message, string type)> OnShowToast { get; set; }
|
||||
|
||||
private BackupOptions exportOptions = new()
|
||||
{
|
||||
IncludeProfiles = true,
|
||||
IncludeCredentials = true,
|
||||
IncludeKeyAssociations = true,
|
||||
IncludeProfileSchedules = true,
|
||||
IncludeOnlyActiveRecords = true
|
||||
};
|
||||
|
||||
private RestoreOptions restoreOptions = new()
|
||||
{
|
||||
RestoreProfiles = true,
|
||||
RestoreCredentials = false, // Default false per sicurezza
|
||||
RestoreKeyAssociations = true,
|
||||
RestoreProfileSchedules = true,
|
||||
OverwriteExisting = false,
|
||||
CreateBackupBeforeRestore = true
|
||||
};
|
||||
|
||||
private bool isExporting = false;
|
||||
private bool isImporting = false;
|
||||
private BackupOperationResult? lastExportResult;
|
||||
private BackupOperationResult? lastImportResult;
|
||||
private SystemBackupData? selectedBackupInfo;
|
||||
private List<FileInfo> recentBackups = new();
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await RefreshBackupList();
|
||||
}
|
||||
|
||||
private async Task ExportBackup()
|
||||
{
|
||||
try
|
||||
{
|
||||
isExporting = true;
|
||||
lastExportResult = null;
|
||||
StateHasChanged();
|
||||
|
||||
lastExportResult = await BackupService.ExportBackupAsync(exportOptions);
|
||||
|
||||
if (lastExportResult.Success)
|
||||
{
|
||||
await OnShowToast.InvokeAsync(($"Backup creato con successo", "success"));
|
||||
await RefreshBackupList();
|
||||
}
|
||||
else
|
||||
{
|
||||
await OnShowToast.InvokeAsync(("Errore durante la creazione del backup", "error"));
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Errore esportazione backup");
|
||||
await OnShowToast.InvokeAsync(($"Errore: {ex.Message}", "error"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
isExporting = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OnFileSelected(InputFileChangeEventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
var file = e.File;
|
||||
if (file != null)
|
||||
{
|
||||
using var stream = file.OpenReadStream(maxAllowedSize: 10 * 1024 * 1024); // 10MB max
|
||||
using var reader = new StreamReader(stream);
|
||||
var content = await reader.ReadToEndAsync();
|
||||
|
||||
selectedBackupInfo = await BackupService.GetBackupInfoAsync(content);
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Errore lettura file backup");
|
||||
await OnShowToast.InvokeAsync(($"Errore lettura file: {ex.Message}", "error"));
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ImportBackup()
|
||||
{
|
||||
try
|
||||
{
|
||||
isImporting = true;
|
||||
lastImportResult = null;
|
||||
StateHasChanged();
|
||||
|
||||
if (selectedBackupInfo != null)
|
||||
{
|
||||
// Serializza il backup selezionato per l'import
|
||||
var backupContent = System.Text.Json.JsonSerializer.Serialize(selectedBackupInfo);
|
||||
|
||||
lastImportResult = await BackupService.ImportBackupFromJsonAsync(backupContent, restoreOptions);
|
||||
|
||||
if (lastImportResult.Success)
|
||||
{
|
||||
await OnShowToast.InvokeAsync(("Backup importato con successo", "success"));
|
||||
}
|
||||
else
|
||||
{
|
||||
await OnShowToast.InvokeAsync(("Errore durante l'importazione", "error"));
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Errore importazione backup");
|
||||
await OnShowToast.InvokeAsync(($"Errore: {ex.Message}", "error"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
isImporting = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private Task RefreshBackupList()
|
||||
{
|
||||
try
|
||||
{
|
||||
var documentsPath = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments);
|
||||
var backupFolder = Path.Combine(documentsPath, "DataCoupler", "Backups");
|
||||
|
||||
if (Directory.Exists(backupFolder))
|
||||
{
|
||||
var files = new DirectoryInfo(backupFolder)
|
||||
.GetFiles("*.json")
|
||||
.OrderByDescending(f => f.CreationTime)
|
||||
.ToList();
|
||||
|
||||
recentBackups = files;
|
||||
}
|
||||
else
|
||||
{
|
||||
recentBackups = new List<FileInfo>();
|
||||
}
|
||||
|
||||
StateHasChanged();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogWarning(ex, "Errore refresh lista backup");
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task DownloadBackup(string filePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
var fileName = Path.GetFileName(filePath);
|
||||
var bytes = await File.ReadAllBytesAsync(filePath);
|
||||
var stream = new MemoryStream(bytes);
|
||||
|
||||
using var streamRef = new DotNetStreamReference(stream);
|
||||
await JSRuntime.InvokeVoidAsync("downloadFileFromStream", fileName, streamRef);
|
||||
|
||||
await OnShowToast.InvokeAsync(($"Download di {fileName} avviato", "info"));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Errore download backup");
|
||||
await OnShowToast.InvokeAsync(($"Errore download: {ex.Message}", "error"));
|
||||
}
|
||||
}
|
||||
|
||||
private string FormatFileSize(long bytes)
|
||||
{
|
||||
string[] sizes = { "B", "KB", "MB", "GB" };
|
||||
double len = bytes;
|
||||
int order = 0;
|
||||
while (len >= 1024 && order < sizes.Length - 1)
|
||||
{
|
||||
order++;
|
||||
len = len / 1024;
|
||||
}
|
||||
return $"{len:0.##} {sizes[order]}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,573 @@
|
||||
@inject ILogger<MaintenanceTab> Logger
|
||||
|
||||
<div class="settings-section">
|
||||
<h4>
|
||||
<i class="fas fa-tools text-success me-2"></i>
|
||||
Manutenzione Sistema
|
||||
</h4>
|
||||
<p class="text-muted">
|
||||
Strumenti per la manutenzione e l'ottimizzazione del sistema.
|
||||
</p>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0">
|
||||
<i class="fas fa-database text-primary me-2"></i>
|
||||
Database
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="card-text">Operazioni di manutenzione del database.</p>
|
||||
|
||||
<div class="mb-3">
|
||||
<h6>Stato Database</h6>
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<span>Dimensione:</span>
|
||||
<span class="badge bg-info">@databaseStats.SizeMB MB</span>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between align-items-center mt-1">
|
||||
<span>Ultima ottimizzazione:</span>
|
||||
<span class="text-muted">@databaseStats.LastOptimization.ToString("dd/MM/yyyy HH:mm")</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-grid gap-2">
|
||||
<button class="btn btn-outline-primary" @onclick="OptimizeDatabase" disabled="@isOptimizing">
|
||||
@if (isOptimizing)
|
||||
{
|
||||
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<i class="fas fa-cogs me-1"></i>
|
||||
}
|
||||
Ottimizza Database
|
||||
</button>
|
||||
|
||||
<button class="btn btn-outline-secondary" @onclick="AnalyzeDatabase" disabled="@isAnalyzing">
|
||||
@if (isAnalyzing)
|
||||
{
|
||||
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<i class="fas fa-search me-1"></i>
|
||||
}
|
||||
Analizza Integrità
|
||||
</button>
|
||||
|
||||
<button class="btn btn-outline-warning" @onclick="CompactDatabase" disabled="@isCompacting">
|
||||
@if (isCompacting)
|
||||
{
|
||||
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<i class="fas fa-compress-alt me-1"></i>
|
||||
}
|
||||
Compatta Database
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0">
|
||||
<i class="fas fa-trash-alt text-warning me-2"></i>
|
||||
Pulizia Sistema
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="card-text">Strumenti per la pulizia dei dati temporanei.</p>
|
||||
|
||||
<div class="mb-3">
|
||||
<h6>File temporanei</h6>
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<span>Dimensione cache:</span>
|
||||
<span class="badge bg-secondary">@cleanupStats.CacheSizeMB MB</span>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between align-items-center mt-1">
|
||||
<span>File di log:</span>
|
||||
<span class="badge bg-secondary">@cleanupStats.LogFileCount file</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-grid gap-2">
|
||||
<button class="btn btn-outline-warning" @onclick="ClearTempFiles" disabled="@isClearingTemp">
|
||||
@if (isClearingTemp)
|
||||
{
|
||||
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<i class="fas fa-broom me-1"></i>
|
||||
}
|
||||
Pulisci File Temporanei
|
||||
</button>
|
||||
|
||||
<button class="btn btn-outline-secondary" @onclick="ClearOldLogs" disabled="@isClearingLogs">
|
||||
@if (isClearingLogs)
|
||||
{
|
||||
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<i class="fas fa-file-alt me-1"></i>
|
||||
}
|
||||
Pulisci Log Vecchi
|
||||
</button>
|
||||
|
||||
<button class="btn btn-outline-info" @onclick="RefreshStats">
|
||||
<i class="fas fa-sync-alt me-1"></i>
|
||||
Aggiorna Statistiche
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mt-4">
|
||||
<div class="col-md-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0">
|
||||
<i class="fas fa-chart-line text-info me-2"></i>
|
||||
Monitoraggio Performance
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-3">
|
||||
<div class="text-center">
|
||||
<h6>Memoria Utilizzata</h6>
|
||||
<div class="progress mb-2">
|
||||
<div class="progress-bar" role="progressbar" style="width: @(performanceStats.MemoryUsagePercent)%"></div>
|
||||
</div>
|
||||
<small class="text-muted">@performanceStats.MemoryUsageMB MB / @performanceStats.TotalMemoryMB MB</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="text-center">
|
||||
<h6>CPU</h6>
|
||||
<div class="progress mb-2">
|
||||
<div class="progress-bar bg-warning" role="progressbar" style="width: @(performanceStats.CpuUsagePercent)%"></div>
|
||||
</div>
|
||||
<small class="text-muted">@performanceStats.CpuUsagePercent%</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="text-center">
|
||||
<h6>Connessioni Attive</h6>
|
||||
<h4 class="text-primary">@performanceStats.ActiveConnections</h4>
|
||||
<small class="text-muted">di @performanceStats.MaxConnections max</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="text-center">
|
||||
<h6>Uptime</h6>
|
||||
<h4 class="text-success">@performanceStats.UptimeHours h</h4>
|
||||
<small class="text-muted">@performanceStats.StartTime.ToString("dd/MM HH:mm")</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<button class="btn btn-outline-primary" @onclick="GeneratePerformanceReport">
|
||||
<i class="fas fa-file-chart-column me-1"></i>
|
||||
Genera Report Performance
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary ms-2" @onclick="RefreshPerformanceStats">
|
||||
<i class="fas fa-sync-alt me-1"></i>
|
||||
Aggiorna
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mt-4">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0">
|
||||
<i class="fas fa-calendar-check text-primary me-2"></i>
|
||||
Manutenzione Programmata
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="card-text">Configurazione delle attività automatiche.</p>
|
||||
|
||||
<div class="form-check mb-3">
|
||||
<input class="form-check-input" type="checkbox" @bind="maintenanceSettings.AutoOptimizeEnabled" id="autoOptimize">
|
||||
<label class="form-check-label" for="autoOptimize">
|
||||
Ottimizzazione automatica database
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-check mb-3">
|
||||
<input class="form-check-input" type="checkbox" @bind="maintenanceSettings.AutoCleanupEnabled" id="autoCleanup">
|
||||
<label class="form-check-label" for="autoCleanup">
|
||||
Pulizia automatica file temporanei
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="maintenanceTime" class="form-label">Orario manutenzione:</label>
|
||||
<input type="time" class="form-control" id="maintenanceTime" @bind="maintenanceSettings.ScheduledTime">
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="maintenanceFrequency" class="form-label">Frequenza:</label>
|
||||
<select class="form-select" id="maintenanceFrequency" @bind="maintenanceSettings.Frequency">
|
||||
<option value="Daily">Giornaliera</option>
|
||||
<option value="Weekly">Settimanale</option>
|
||||
<option value="Monthly">Mensile</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary" @onclick="SaveMaintenanceSettings">
|
||||
<i class="fas fa-save me-1"></i>
|
||||
Salva Configurazione
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0">
|
||||
<i class="fas fa-history text-info me-2"></i>
|
||||
Storico Attività
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="card-text">Ultime operazioni di manutenzione.</p>
|
||||
|
||||
<div class="maintenance-history">
|
||||
@foreach (var activity in maintenanceHistory)
|
||||
{
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<div>
|
||||
<i class="@GetActivityIcon(activity.Type) me-2"></i>
|
||||
@activity.Description
|
||||
</div>
|
||||
<small class="text-muted">@activity.Timestamp.ToString("dd/MM HH:mm")</small>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (!maintenanceHistory.Any())
|
||||
{
|
||||
<div class="text-muted text-center py-3">
|
||||
<i class="fas fa-info-circle me-2"></i>
|
||||
Nessuna attività di manutenzione recente
|
||||
</div>
|
||||
}
|
||||
|
||||
<button class="btn btn-outline-secondary btn-sm mt-2" @onclick="ClearMaintenanceHistory">
|
||||
<i class="fas fa-trash-alt me-1"></i>
|
||||
Pulisci Storico
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public EventCallback<(string message, string type)> OnShowToast { get; set; }
|
||||
|
||||
private bool isOptimizing = false;
|
||||
private bool isAnalyzing = false;
|
||||
private bool isCompacting = false;
|
||||
private bool isClearingTemp = false;
|
||||
private bool isClearingLogs = false;
|
||||
|
||||
private DatabaseStats databaseStats = new()
|
||||
{
|
||||
SizeMB = 12.5,
|
||||
LastOptimization = DateTime.Now.AddDays(-3)
|
||||
};
|
||||
|
||||
private CleanupStats cleanupStats = new()
|
||||
{
|
||||
CacheSizeMB = 45.2,
|
||||
LogFileCount = 127
|
||||
};
|
||||
|
||||
private PerformanceStats performanceStats = new()
|
||||
{
|
||||
MemoryUsageMB = 234,
|
||||
TotalMemoryMB = 512,
|
||||
MemoryUsagePercent = 45,
|
||||
CpuUsagePercent = 12,
|
||||
ActiveConnections = 3,
|
||||
MaxConnections = 100,
|
||||
UptimeHours = 72,
|
||||
StartTime = DateTime.Now.AddHours(-72)
|
||||
};
|
||||
|
||||
private MaintenanceSettings maintenanceSettings = new()
|
||||
{
|
||||
AutoOptimizeEnabled = true,
|
||||
AutoCleanupEnabled = true,
|
||||
ScheduledTime = new TimeOnly(2, 0),
|
||||
Frequency = "Weekly"
|
||||
};
|
||||
|
||||
private List<MaintenanceActivity> maintenanceHistory = new()
|
||||
{
|
||||
new MaintenanceActivity { Type = "Optimization", Description = "Database ottimizzato", Timestamp = DateTime.Now.AddHours(-6) },
|
||||
new MaintenanceActivity { Type = "Cleanup", Description = "File temporanei rimossi", Timestamp = DateTime.Now.AddDays(-1) },
|
||||
new MaintenanceActivity { Type = "Backup", Description = "Backup automatico completato", Timestamp = DateTime.Now.AddDays(-2) }
|
||||
};
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await RefreshStats();
|
||||
}
|
||||
|
||||
private async Task OptimizeDatabase()
|
||||
{
|
||||
try
|
||||
{
|
||||
isOptimizing = true;
|
||||
StateHasChanged();
|
||||
|
||||
await Task.Delay(3000); // Simula ottimizzazione
|
||||
|
||||
databaseStats.LastOptimization = DateTime.Now;
|
||||
AddMaintenanceActivity("Optimization", "Database ottimizzato");
|
||||
|
||||
await OnShowToast.InvokeAsync(("Database ottimizzato con successo", "success"));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Errore ottimizzazione database");
|
||||
await OnShowToast.InvokeAsync(("Errore durante l'ottimizzazione", "error"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
isOptimizing = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AnalyzeDatabase()
|
||||
{
|
||||
try
|
||||
{
|
||||
isAnalyzing = true;
|
||||
StateHasChanged();
|
||||
|
||||
await Task.Delay(2000);
|
||||
await OnShowToast.InvokeAsync(("Analisi completata: nessun problema rilevato", "success"));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Errore analisi database");
|
||||
await OnShowToast.InvokeAsync(("Errore durante l'analisi", "error"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
isAnalyzing = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CompactDatabase()
|
||||
{
|
||||
try
|
||||
{
|
||||
isCompacting = true;
|
||||
StateHasChanged();
|
||||
|
||||
await Task.Delay(4000);
|
||||
|
||||
databaseStats.SizeMB *= 0.8; // Simula riduzione dimensioni
|
||||
AddMaintenanceActivity("Compact", "Database compattato");
|
||||
|
||||
await OnShowToast.InvokeAsync(("Database compattato con successo", "success"));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Errore compattazione database");
|
||||
await OnShowToast.InvokeAsync(("Errore durante la compattazione", "error"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
isCompacting = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ClearTempFiles()
|
||||
{
|
||||
try
|
||||
{
|
||||
isClearingTemp = true;
|
||||
StateHasChanged();
|
||||
|
||||
await Task.Delay(1500);
|
||||
|
||||
cleanupStats.CacheSizeMB = 0;
|
||||
AddMaintenanceActivity("Cleanup", "File temporanei rimossi");
|
||||
|
||||
await OnShowToast.InvokeAsync(("File temporanei rimossi", "success"));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Errore pulizia file temporanei");
|
||||
await OnShowToast.InvokeAsync(("Errore durante la pulizia", "error"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
isClearingTemp = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ClearOldLogs()
|
||||
{
|
||||
try
|
||||
{
|
||||
isClearingLogs = true;
|
||||
StateHasChanged();
|
||||
|
||||
await Task.Delay(1000);
|
||||
|
||||
cleanupStats.LogFileCount = Math.Max(0, cleanupStats.LogFileCount - 50);
|
||||
AddMaintenanceActivity("LogCleanup", "Log vecchi rimossi");
|
||||
|
||||
await OnShowToast.InvokeAsync(("Log vecchi rimossi", "success"));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Errore pulizia log");
|
||||
await OnShowToast.InvokeAsync(("Errore durante la pulizia log", "error"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
isClearingLogs = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RefreshStats()
|
||||
{
|
||||
await Task.Delay(300);
|
||||
// Simula aggiornamento statistiche
|
||||
cleanupStats.CacheSizeMB = Random.Shared.Next(20, 80);
|
||||
cleanupStats.LogFileCount = Random.Shared.Next(50, 200);
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task RefreshPerformanceStats()
|
||||
{
|
||||
await Task.Delay(500);
|
||||
// Simula aggiornamento performance
|
||||
performanceStats.MemoryUsagePercent = Random.Shared.Next(30, 70);
|
||||
performanceStats.CpuUsagePercent = Random.Shared.Next(5, 25);
|
||||
performanceStats.ActiveConnections = Random.Shared.Next(1, 10);
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task GeneratePerformanceReport()
|
||||
{
|
||||
await OnShowToast.InvokeAsync(("Report performance generato", "info"));
|
||||
}
|
||||
|
||||
private async Task SaveMaintenanceSettings()
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(300);
|
||||
await OnShowToast.InvokeAsync(("Configurazione manutenzione salvata", "success"));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Errore salvataggio configurazione");
|
||||
await OnShowToast.InvokeAsync(("Errore salvataggio configurazione", "error"));
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ClearMaintenanceHistory()
|
||||
{
|
||||
maintenanceHistory.Clear();
|
||||
await OnShowToast.InvokeAsync(("Storico attività pulito", "info"));
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private void AddMaintenanceActivity(string type, string description)
|
||||
{
|
||||
maintenanceHistory.Insert(0, new MaintenanceActivity
|
||||
{
|
||||
Type = type,
|
||||
Description = description,
|
||||
Timestamp = DateTime.Now
|
||||
});
|
||||
|
||||
// Mantieni solo le ultime 10 attività
|
||||
if (maintenanceHistory.Count > 10)
|
||||
{
|
||||
maintenanceHistory.RemoveAt(maintenanceHistory.Count - 1);
|
||||
}
|
||||
}
|
||||
|
||||
private string GetActivityIcon(string type) => type switch
|
||||
{
|
||||
"Optimization" => "fas fa-cogs text-primary",
|
||||
"Cleanup" => "fas fa-broom text-warning",
|
||||
"Backup" => "fas fa-download text-success",
|
||||
"Compact" => "fas fa-compress-alt text-info",
|
||||
"LogCleanup" => "fas fa-file-alt text-secondary",
|
||||
_ => "fas fa-cog text-muted"
|
||||
};
|
||||
|
||||
private class DatabaseStats
|
||||
{
|
||||
public double SizeMB { get; set; }
|
||||
public DateTime LastOptimization { get; set; }
|
||||
}
|
||||
|
||||
private class CleanupStats
|
||||
{
|
||||
public double CacheSizeMB { get; set; }
|
||||
public int LogFileCount { get; set; }
|
||||
}
|
||||
|
||||
private class PerformanceStats
|
||||
{
|
||||
public int MemoryUsageMB { get; set; }
|
||||
public int TotalMemoryMB { get; set; }
|
||||
public int MemoryUsagePercent { get; set; }
|
||||
public int CpuUsagePercent { get; set; }
|
||||
public int ActiveConnections { get; set; }
|
||||
public int MaxConnections { get; set; }
|
||||
public int UptimeHours { get; set; }
|
||||
public DateTime StartTime { get; set; }
|
||||
}
|
||||
|
||||
private class MaintenanceSettings
|
||||
{
|
||||
public bool AutoOptimizeEnabled { get; set; }
|
||||
public bool AutoCleanupEnabled { get; set; }
|
||||
public TimeOnly ScheduledTime { get; set; }
|
||||
public string Frequency { get; set; } = "Weekly";
|
||||
}
|
||||
|
||||
private class MaintenanceActivity
|
||||
{
|
||||
public string Type { get; set; } = "";
|
||||
public string Description { get; set; } = "";
|
||||
public DateTime Timestamp { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
@inject ILogger<SecurityTab> Logger
|
||||
|
||||
<div class="settings-section">
|
||||
<h4>
|
||||
<i class="fas fa-shield-alt text-primary me-2"></i>
|
||||
Sicurezza e Privacy
|
||||
</h4>
|
||||
<p class="text-muted">
|
||||
Configurazioni per la sicurezza dei dati e la gestione delle credenziali.
|
||||
</p>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0">
|
||||
<i class="fas fa-lock text-danger me-2"></i>
|
||||
Crittografia
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="card-text">Gestione della crittografia delle credenziali.</p>
|
||||
|
||||
<div class="alert alert-info">
|
||||
<i class="fas fa-info-circle me-2"></i>
|
||||
<strong>Data Protection API Attiva</strong><br>
|
||||
Le credenziali sono crittografate utilizzando la Data Protection API di .NET.
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<span>Stato Crittografia:</span>
|
||||
<span class="badge bg-success">
|
||||
<i class="fas fa-check-circle me-1"></i>
|
||||
Attiva
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<span>Algoritmo:</span>
|
||||
<span class="badge bg-info">AES-256</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-outline-warning btn-sm" @onclick="RegenerateKeys" disabled="@isRegeneratingKeys">
|
||||
@if (isRegeneratingKeys)
|
||||
{
|
||||
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<i class="fas fa-key me-1"></i>
|
||||
}
|
||||
Rigenera Chiavi
|
||||
</button>
|
||||
<div class="form-text">
|
||||
<i class="fas fa-exclamation-triangle text-warning me-1"></i>
|
||||
Attenzione: La rigenerazione delle chiavi renderà inaccessibili le credenziali esistenti
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0">
|
||||
<i class="fas fa-user-shield text-success me-2"></i>
|
||||
Audit e Logging
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="card-text">Configurazione del logging di sicurezza.</p>
|
||||
|
||||
<div class="form-check mb-3">
|
||||
<input class="form-check-input" type="checkbox" @bind="securitySettings.LogCredentialAccess" id="logCredentialAccess">
|
||||
<label class="form-check-label" for="logCredentialAccess">
|
||||
Log accesso alle credenziali
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-check mb-3">
|
||||
<input class="form-check-input" type="checkbox" @bind="securitySettings.LogDataTransfers" id="logDataTransfers">
|
||||
<label class="form-check-label" for="logDataTransfers">
|
||||
Log trasferimenti dati
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-check mb-3">
|
||||
<input class="form-check-input" type="checkbox" @bind="securitySettings.LogFailedOperations" id="logFailedOperations">
|
||||
<label class="form-check-label" for="logFailedOperations">
|
||||
Log operazioni fallite
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="logRetentionDays" class="form-label">Giorni di ritenzione log:</label>
|
||||
<input type="number" class="form-control" id="logRetentionDays" @bind="securitySettings.LogRetentionDays"
|
||||
min="1" max="365" step="1">
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary btn-sm" @onclick="SaveSecuritySettings">
|
||||
<i class="fas fa-save me-1"></i>
|
||||
Salva Impostazioni
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mt-4">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0">
|
||||
<i class="fas fa-network-wired text-primary me-2"></i>
|
||||
Connessioni Sicure
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="card-text">Impostazioni per le connessioni di rete.</p>
|
||||
|
||||
<div class="form-check mb-3">
|
||||
<input class="form-check-input" type="checkbox" @bind="securitySettings.EnforceHttps" id="enforceHttps">
|
||||
<label class="form-check-label" for="enforceHttps">
|
||||
Forza HTTPS per API REST
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-check mb-3">
|
||||
<input class="form-check-input" type="checkbox" @bind="securitySettings.ValidateSslCertificates" id="validateSsl">
|
||||
<label class="form-check-label" for="validateSsl">
|
||||
Valida certificati SSL/TLS
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="connectionTimeout" class="form-label">Timeout connessioni (secondi):</label>
|
||||
<input type="number" class="form-control" id="connectionTimeout" @bind="securitySettings.ConnectionTimeoutSeconds"
|
||||
min="5" max="300" step="5">
|
||||
</div>
|
||||
|
||||
<div class="alert alert-warning">
|
||||
<i class="fas fa-exclamation-triangle me-2"></i>
|
||||
<strong>Nota:</strong> Disabilitare la validazione SSL solo in ambienti di sviluppo sicuri.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0">
|
||||
<i class="fas fa-database text-warning me-2"></i>
|
||||
Backup Sicurezza
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="card-text">Configurazione dei backup di sicurezza.</p>
|
||||
|
||||
<div class="form-check mb-3">
|
||||
<input class="form-check-input" type="checkbox" @bind="securitySettings.AutoBackupEnabled" id="autoBackup">
|
||||
<label class="form-check-label" for="autoBackup">
|
||||
Backup automatico giornaliero
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-check mb-3">
|
||||
<input class="form-check-input" type="checkbox" @bind="securitySettings.EncryptBackups" id="encryptBackups">
|
||||
<label class="form-check-label" for="encryptBackups">
|
||||
Cripta file di backup
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="backupRetentionDays" class="form-label">Giorni di ritenzione backup:</label>
|
||||
<input type="number" class="form-control" id="backupRetentionDays" @bind="securitySettings.BackupRetentionDays"
|
||||
min="1" max="365" step="1">
|
||||
</div>
|
||||
|
||||
@if (securitySettings.AutoBackupEnabled)
|
||||
{
|
||||
<div class="alert alert-info">
|
||||
<i class="fas fa-clock me-2"></i>
|
||||
Prossimo backup automatico: <strong>Domani alle 02:00</strong>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mt-4">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0">
|
||||
<i class="fas fa-exclamation-triangle text-danger me-2"></i>
|
||||
Azioni Critiche
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h6>Pulizia Cache Credenziali</h6>
|
||||
<p class="text-muted">Pulisce la cache delle credenziali in memoria.</p>
|
||||
<button class="btn btn-outline-warning" @onclick="ClearCredentialCache">
|
||||
<i class="fas fa-broom me-1"></i>
|
||||
Pulisci Cache
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<h6>Reset Configurazione Sicurezza</h6>
|
||||
<p class="text-muted">Ripristina le impostazioni di sicurezza ai valori predefiniti.</p>
|
||||
<button class="btn btn-outline-danger" @onclick="ResetSecuritySettings">
|
||||
<i class="fas fa-undo me-1"></i>
|
||||
Reset Impostazioni
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public EventCallback<(string message, string type)> OnShowToast { get; set; }
|
||||
|
||||
private bool isRegeneratingKeys = false;
|
||||
private SecuritySettings securitySettings = new()
|
||||
{
|
||||
LogCredentialAccess = true,
|
||||
LogDataTransfers = true,
|
||||
LogFailedOperations = true,
|
||||
LogRetentionDays = 30,
|
||||
EnforceHttps = true,
|
||||
ValidateSslCertificates = true,
|
||||
ConnectionTimeoutSeconds = 30,
|
||||
AutoBackupEnabled = false,
|
||||
EncryptBackups = true,
|
||||
BackupRetentionDays = 30
|
||||
};
|
||||
|
||||
private async Task SaveSecuritySettings()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Implementazione semplificata - in produzione salvare nel database o file di configurazione
|
||||
await Task.Delay(500);
|
||||
|
||||
await OnShowToast.InvokeAsync(("Impostazioni di sicurezza salvate", "success"));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Errore salvataggio impostazioni sicurezza");
|
||||
await OnShowToast.InvokeAsync(("Errore salvataggio impostazioni", "error"));
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RegenerateKeys()
|
||||
{
|
||||
try
|
||||
{
|
||||
isRegeneratingKeys = true;
|
||||
StateHasChanged();
|
||||
|
||||
// Simula rigenerazione chiavi
|
||||
await Task.Delay(2000);
|
||||
|
||||
await OnShowToast.InvokeAsync(("Chiavi di crittografia rigenerate", "success"));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Errore rigenerazione chiavi");
|
||||
await OnShowToast.InvokeAsync(("Errore rigenerazione chiavi", "error"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
isRegeneratingKeys = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ClearCredentialCache()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Implementazione semplificata
|
||||
await Task.Delay(500);
|
||||
|
||||
await OnShowToast.InvokeAsync(("Cache credenziali pulita", "success"));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Errore pulizia cache");
|
||||
await OnShowToast.InvokeAsync(("Errore pulizia cache", "error"));
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ResetSecuritySettings()
|
||||
{
|
||||
try
|
||||
{
|
||||
securitySettings = new SecuritySettings
|
||||
{
|
||||
LogCredentialAccess = true,
|
||||
LogDataTransfers = true,
|
||||
LogFailedOperations = true,
|
||||
LogRetentionDays = 30,
|
||||
EnforceHttps = true,
|
||||
ValidateSslCertificates = true,
|
||||
ConnectionTimeoutSeconds = 30,
|
||||
AutoBackupEnabled = false,
|
||||
EncryptBackups = true,
|
||||
BackupRetentionDays = 30
|
||||
};
|
||||
|
||||
await Task.Delay(300);
|
||||
await OnShowToast.InvokeAsync(("Impostazioni di sicurezza ripristinate", "info"));
|
||||
StateHasChanged();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Errore reset impostazioni");
|
||||
await OnShowToast.InvokeAsync(("Errore reset impostazioni", "error"));
|
||||
}
|
||||
}
|
||||
|
||||
private class SecuritySettings
|
||||
{
|
||||
public bool LogCredentialAccess { get; set; }
|
||||
public bool LogDataTransfers { get; set; }
|
||||
public bool LogFailedOperations { get; set; }
|
||||
public int LogRetentionDays { get; set; }
|
||||
public bool EnforceHttps { get; set; }
|
||||
public bool ValidateSslCertificates { get; set; }
|
||||
public int ConnectionTimeoutSeconds { get; set; }
|
||||
public bool AutoBackupEnabled { get; set; }
|
||||
public bool EncryptBackups { get; set; }
|
||||
public int BackupRetentionDays { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
@inject ILogger<SystemTab> Logger
|
||||
|
||||
<div class="settings-section">
|
||||
<h4>
|
||||
<i class="fas fa-server text-primary me-2"></i>
|
||||
Configurazione Sistema
|
||||
</h4>
|
||||
<p class="text-muted">
|
||||
Impostazioni generali del sistema Data Coupler.
|
||||
</p>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0">
|
||||
<i class="fas fa-database text-info me-2"></i>
|
||||
Database
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="card-text">Configurazione del database principale.</p>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Percorso Database:</label>
|
||||
<input type="text" class="form-control" value="@GetDatabasePath()" readonly>
|
||||
<div class="form-text">Percorso del database SQLite di sistema</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Statistiche Database:</label>
|
||||
<div class="row">
|
||||
<div class="col-6">
|
||||
<div class="text-center p-2 bg-light rounded">
|
||||
<div class="h5 mb-1">@databaseStats.TotalProfiles</div>
|
||||
<small class="text-muted">Profili</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="text-center p-2 bg-light rounded">
|
||||
<div class="h5 mb-1">@databaseStats.TotalCredentials</div>
|
||||
<small class="text-muted">Credenziali</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-outline-primary btn-sm" @onclick="RefreshStats">
|
||||
<i class="fas fa-refresh me-1"></i>
|
||||
Aggiorna Statistiche
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0">
|
||||
<i class="fas fa-cogs text-warning me-2"></i>
|
||||
Performance
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="card-text">Impostazioni per ottimizzare le performance.</p>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="batchSize" class="form-label">Dimensione Batch Predefinita:</label>
|
||||
<input type="number" class="form-control" id="batchSize" @bind="systemSettings.DefaultBatchSize"
|
||||
min="1" max="1000" step="1">
|
||||
<div class="form-text">Numero di record da processare per volta</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="timeout" class="form-label">Timeout Connessioni (secondi):</label>
|
||||
<input type="number" class="form-control" id="timeout" @bind="systemSettings.DefaultTimeout"
|
||||
min="10" max="300" step="5">
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary btn-sm" @onclick="SaveSystemSettings">
|
||||
<i class="fas fa-save me-1"></i>
|
||||
Salva Impostazioni
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mt-4">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0">
|
||||
<i class="fas fa-info-circle text-success me-2"></i>
|
||||
Informazioni Sistema
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-3">
|
||||
<strong>Versione Applicazione:</strong><br>
|
||||
<span class="badge bg-primary">1.0.0</span>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<strong>Framework:</strong><br>
|
||||
<span class="badge bg-info">.NET 9.0</span>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<strong>Sistema Operativo:</strong><br>
|
||||
<span class="badge bg-secondary">@Environment.OSVersion.Platform</span>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<strong>Memoria Utilizzata:</strong><br>
|
||||
<span class="badge bg-warning">@GetMemoryUsage()</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public EventCallback<(string message, string type)> OnShowToast { get; set; }
|
||||
|
||||
private DatabaseStats databaseStats = new();
|
||||
private SystemSettings systemSettings = new()
|
||||
{
|
||||
DefaultBatchSize = 200,
|
||||
DefaultTimeout = 30
|
||||
};
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await RefreshStats();
|
||||
}
|
||||
|
||||
private string GetDatabasePath()
|
||||
{
|
||||
// Implementazione semplificata - in un'implementazione reale
|
||||
// questo dovrebbe venire dalla configurazione
|
||||
var documentsPath = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments);
|
||||
return Path.Combine(documentsPath, "DataCoupler", "credentials.db");
|
||||
}
|
||||
|
||||
private string GetMemoryUsage()
|
||||
{
|
||||
var workingSet = Environment.WorkingSet;
|
||||
return $"{workingSet / 1024 / 1024:F1} MB";
|
||||
}
|
||||
|
||||
private async Task RefreshStats()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Implementazione semplificata - in produzione collegarsi al database
|
||||
databaseStats = new DatabaseStats
|
||||
{
|
||||
TotalProfiles = Random.Shared.Next(5, 50),
|
||||
TotalCredentials = Random.Shared.Next(3, 20)
|
||||
};
|
||||
|
||||
await OnShowToast.InvokeAsync(("Statistiche aggiornate", "success"));
|
||||
StateHasChanged();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Errore aggiornamento statistiche");
|
||||
await OnShowToast.InvokeAsync(("Errore aggiornamento statistiche", "error"));
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SaveSystemSettings()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Implementazione semplificata - in produzione salvare nel database o file di configurazione
|
||||
await Task.Delay(500); // Simula operazione di salvataggio
|
||||
|
||||
await OnShowToast.InvokeAsync(("Impostazioni salvate con successo", "success"));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Errore salvataggio impostazioni");
|
||||
await OnShowToast.InvokeAsync(("Errore salvataggio impostazioni", "error"));
|
||||
}
|
||||
}
|
||||
|
||||
private class DatabaseStats
|
||||
{
|
||||
public int TotalProfiles { get; set; }
|
||||
public int TotalCredentials { get; set; }
|
||||
}
|
||||
|
||||
private class SystemSettings
|
||||
{
|
||||
public int DefaultBatchSize { get; set; }
|
||||
public int DefaultTimeout { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -140,19 +140,12 @@ public partial class DataCoupler : ComponentBase
|
||||
|
||||
Logger.LogInformation("Autenticazione completata con successo per il servizio REST {ServiceType}", credential.ServiceType);
|
||||
|
||||
// Discovery delle entità disponibili
|
||||
Logger.LogInformation("Iniziando discovery delle entità REST...");
|
||||
var entities = await currentRestDiscovery.DiscoverEntitiesAsync();
|
||||
|
||||
restEntities = entities.Select(e => new RestEntitySummary
|
||||
{
|
||||
Name = e.Name,
|
||||
Label = e.Name, // Use Name as Label since RestEntityInfo doesn't have Label
|
||||
Description = "" // RestEntityInfo doesn't have Description
|
||||
}).ToList();
|
||||
// Discovery delle entità disponibili usando il metodo batch ottimizzato
|
||||
Logger.LogInformation("Iniziando discovery batch delle entità REST...");
|
||||
restEntities = await currentRestDiscovery.DiscoverEntitySummariesAsync();
|
||||
isRestConnected = true;
|
||||
|
||||
Logger.LogInformation("Discovery completato: trovate {EntityCount} entità REST", restEntities.Count);
|
||||
Logger.LogInformation("Discovery batch completato: trovate {EntityCount} entità REST", restEntities.Count);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json.Serialization;
|
||||
using CredentialManager.Models;
|
||||
|
||||
namespace Data_Coupler.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Modello per l'export/import completo dei dati di backup
|
||||
/// </summary>
|
||||
public class SystemBackupData
|
||||
{
|
||||
[JsonPropertyName("metadata")]
|
||||
public BackupMetadata Metadata { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("profiles")]
|
||||
public List<DataCouplerProfileBackup> Profiles { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("credentials")]
|
||||
public List<CredentialBackup> Credentials { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("keyAssociations")]
|
||||
public List<KeyAssociationBackup> KeyAssociations { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("profileSchedules")]
|
||||
public List<ProfileScheduleBackup> ProfileSchedules { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Metadati del backup
|
||||
/// </summary>
|
||||
public class BackupMetadata
|
||||
{
|
||||
[JsonPropertyName("version")]
|
||||
public string Version { get; set; } = "1.0";
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
[JsonPropertyName("createdBy")]
|
||||
public string? CreatedBy { get; set; }
|
||||
|
||||
[JsonPropertyName("applicationVersion")]
|
||||
public string ApplicationVersion { get; set; } = "1.0.0";
|
||||
|
||||
[JsonPropertyName("totalRecords")]
|
||||
public BackupRecordCount RecordCounts { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Conteggio record nel backup
|
||||
/// </summary>
|
||||
public class BackupRecordCount
|
||||
{
|
||||
[JsonPropertyName("profiles")]
|
||||
public int Profiles { get; set; }
|
||||
|
||||
[JsonPropertyName("credentials")]
|
||||
public int Credentials { get; set; }
|
||||
|
||||
[JsonPropertyName("keyAssociations")]
|
||||
public int KeyAssociations { get; set; }
|
||||
|
||||
[JsonPropertyName("profileSchedules")]
|
||||
public int ProfileSchedules { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Backup di un profilo DataCoupler
|
||||
/// </summary>
|
||||
public class DataCouplerProfileBackup
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public int Id { get; set; }
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; set; }
|
||||
|
||||
[JsonPropertyName("sourceType")]
|
||||
public string SourceType { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("sourceCredentialName")]
|
||||
public string? SourceCredentialName { get; set; }
|
||||
|
||||
[JsonPropertyName("sourceDatabaseName")]
|
||||
public string? SourceDatabaseName { get; set; }
|
||||
|
||||
[JsonPropertyName("sourceSchema")]
|
||||
public string? SourceSchema { get; set; }
|
||||
|
||||
[JsonPropertyName("sourceTable")]
|
||||
public string? SourceTable { get; set; }
|
||||
|
||||
[JsonPropertyName("sourceCustomQuery")]
|
||||
public string? SourceCustomQuery { get; set; }
|
||||
|
||||
[JsonPropertyName("sourceFilePath")]
|
||||
public string? SourceFilePath { get; set; }
|
||||
|
||||
[JsonPropertyName("destinationType")]
|
||||
public string DestinationType { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("destinationCredentialName")]
|
||||
public string? DestinationCredentialName { get; set; }
|
||||
|
||||
[JsonPropertyName("destinationSchema")]
|
||||
public string? DestinationSchema { get; set; }
|
||||
|
||||
[JsonPropertyName("destinationTable")]
|
||||
public string? DestinationTable { get; set; }
|
||||
|
||||
[JsonPropertyName("destinationEndpoint")]
|
||||
public string? DestinationEndpoint { get; set; }
|
||||
|
||||
[JsonPropertyName("fieldMappings")]
|
||||
public List<FieldMappingDto>? FieldMappings { get; set; }
|
||||
|
||||
[JsonPropertyName("sourceKeyField")]
|
||||
public string? SourceKeyField { get; set; }
|
||||
|
||||
[JsonPropertyName("useRecordAssociations")]
|
||||
public bool UseRecordAssociations { get; set; }
|
||||
|
||||
[JsonPropertyName("createdBy")]
|
||||
public string? CreatedBy { get; set; }
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
[JsonPropertyName("lastUsedAt")]
|
||||
public DateTime? LastUsedAt { get; set; }
|
||||
|
||||
[JsonPropertyName("isActive")]
|
||||
public bool IsActive { get; set; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Backup di una credenziale (dati sensibili esclusi per sicurezza)
|
||||
/// </summary>
|
||||
public class CredentialBackup
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public int Id { get; set; }
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("databaseType")]
|
||||
public string? DatabaseType { get; set; }
|
||||
|
||||
[JsonPropertyName("host")]
|
||||
public string? Host { get; set; }
|
||||
|
||||
[JsonPropertyName("port")]
|
||||
public int? Port { get; set; }
|
||||
|
||||
[JsonPropertyName("databaseName")]
|
||||
public string? DatabaseName { get; set; }
|
||||
|
||||
[JsonPropertyName("username")]
|
||||
public string? Username { get; set; }
|
||||
|
||||
// NOTA: Password, API Keys e Token NON vengono inclusi nel backup per sicurezza
|
||||
|
||||
[JsonPropertyName("commandTimeout")]
|
||||
public int CommandTimeout { get; set; } = 30;
|
||||
|
||||
[JsonPropertyName("timeoutSeconds")]
|
||||
public int TimeoutSeconds { get; set; } = 100;
|
||||
|
||||
[JsonPropertyName("ignoreSslErrors")]
|
||||
public bool IgnoreSslErrors { get; set; } = false;
|
||||
|
||||
[JsonPropertyName("restServiceType")]
|
||||
public string? RestServiceType { get; set; }
|
||||
|
||||
[JsonPropertyName("headers")]
|
||||
public string? Headers { get; set; }
|
||||
|
||||
[JsonPropertyName("additionalParameters")]
|
||||
public string? AdditionalParameters { get; set; }
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
[JsonPropertyName("updatedAt")]
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
|
||||
[JsonPropertyName("createdBy")]
|
||||
public string? CreatedBy { get; set; }
|
||||
|
||||
[JsonPropertyName("isActive")]
|
||||
public bool IsActive { get; set; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Backup di un'associazione chiave
|
||||
/// </summary>
|
||||
public class KeyAssociationBackup
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public int Id { get; set; }
|
||||
|
||||
[JsonPropertyName("keyValue")]
|
||||
public string KeyValue { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("sourceKeyField")]
|
||||
public string SourceKeyField { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("destinationKeyField")]
|
||||
public string DestinationKeyField { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("destinationEntity")]
|
||||
public string DestinationEntity { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("destinationId")]
|
||||
public string DestinationId { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("restCredentialName")]
|
||||
public string RestCredentialName { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
[JsonPropertyName("updatedAt")]
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
|
||||
[JsonPropertyName("lastVerifiedAt")]
|
||||
public DateTime? LastVerifiedAt { get; set; }
|
||||
|
||||
[JsonPropertyName("isActive")]
|
||||
public bool IsActive { get; set; } = true;
|
||||
|
||||
[JsonPropertyName("dataHash")]
|
||||
public string? DataHash { get; set; }
|
||||
|
||||
[JsonPropertyName("sourcesInfo")]
|
||||
public string? SourcesInfo { get; set; }
|
||||
|
||||
[JsonPropertyName("additionalInfo")]
|
||||
public string? AdditionalInfo { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Backup di una schedule profilo
|
||||
/// </summary>
|
||||
public class ProfileScheduleBackup
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public int Id { get; set; }
|
||||
|
||||
[JsonPropertyName("profileName")]
|
||||
public string ProfileName { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("scheduleType")]
|
||||
public string ScheduleType { get; set; } = string.Empty; // "daily", "weekly", "monthly"
|
||||
|
||||
[JsonPropertyName("dailyExecutionTime")]
|
||||
public TimeSpan? DailyExecutionTime { get; set; }
|
||||
|
||||
[JsonPropertyName("weeklyDayOfWeek")]
|
||||
public int? WeeklyDayOfWeek { get; set; } // 0 = Sunday, 1 = Monday, etc.
|
||||
|
||||
[JsonPropertyName("monthlyDayOfMonth")]
|
||||
public int? MonthlyDayOfMonth { get; set; } // 1-31
|
||||
|
||||
[JsonPropertyName("isActive")]
|
||||
public bool IsActive { get; set; } = true;
|
||||
|
||||
[JsonPropertyName("isPaused")]
|
||||
public bool IsPaused { get; set; } = false;
|
||||
|
||||
[JsonPropertyName("lastExecuted")]
|
||||
public DateTime? LastExecuted { get; set; }
|
||||
|
||||
[JsonPropertyName("nextExecution")]
|
||||
public DateTime? NextExecution { get; set; }
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
[JsonPropertyName("createdBy")]
|
||||
public string? CreatedBy { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Risultato dell'operazione di backup/restore
|
||||
/// </summary>
|
||||
public class BackupOperationResult
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public string? Message { get; set; }
|
||||
public TimeSpan Duration { get; set; }
|
||||
public BackupRecordCount ProcessedCounts { get; set; } = new();
|
||||
public List<string> Warnings { get; set; } = new();
|
||||
public List<string> Errors { get; set; } = new();
|
||||
public string? FilePath { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opzioni per l'operazione di backup
|
||||
/// </summary>
|
||||
public class BackupOptions
|
||||
{
|
||||
public bool IncludeProfiles { get; set; } = true;
|
||||
public bool IncludeCredentials { get; set; } = true;
|
||||
public bool IncludeKeyAssociations { get; set; } = true;
|
||||
public bool IncludeProfileSchedules { get; set; } = true;
|
||||
public bool IncludeOnlyActiveRecords { get; set; } = true;
|
||||
public string? Description { get; set; }
|
||||
public string? CreatedBy { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opzioni per l'operazione di restore
|
||||
/// </summary>
|
||||
public class RestoreOptions
|
||||
{
|
||||
public bool RestoreProfiles { get; set; } = true;
|
||||
public bool RestoreCredentials { get; set; } = false; // Default false per sicurezza
|
||||
public bool RestoreKeyAssociations { get; set; } = true;
|
||||
public bool RestoreProfileSchedules { get; set; } = true;
|
||||
public bool OverwriteExisting { get; set; } = false;
|
||||
public bool CreateBackupBeforeRestore { get; set; } = true;
|
||||
public string? ImportedBy { get; set; }
|
||||
}
|
||||
@@ -1812,36 +1812,25 @@ public partial class DataCoupler : ComponentBase
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Genera un hash SHA256 dei dati dei campi sorgente mappati.
|
||||
/// Genera un hash SHA256 dei dati del record passato come parametro.
|
||||
/// Utilizzato per rilevare cambiamenti nei dati e ottimizzare il trasferimento.
|
||||
/// Include anche una signature dei campi mappati per rilevare cambi di configurazione.
|
||||
/// Calcola l'hash SOLO sui campi presenti nel record, in ordine alfabetico.
|
||||
/// </summary>
|
||||
private string GenerateDataHash(Dictionary<string, object> record)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Raccoglie i valori dei campi mappati in ordine alfabetico per garantire consistenza
|
||||
var mappedFields = fieldMappings.Keys.OrderBy(k => k).ToList();
|
||||
var valuesForHash = new List<string>();
|
||||
|
||||
// PRIMO: Aggiungi la signature dei mapping per rilevare cambi di configurazione
|
||||
var mappingSignature = string.Join(",", fieldMappings.OrderBy(m => m.Key).Select(m => $"{m.Key}->{m.Value}"));
|
||||
valuesForHash.Add($"MAPPING_SIGNATURE={mappingSignature}");
|
||||
// Ordina le chiavi alfabeticamente per garantire consistenza
|
||||
var orderedKeys = record.Keys.OrderBy(k => k).ToList();
|
||||
|
||||
// SECONDO: Aggiungi i valori dei dati per ogni campo mappato
|
||||
foreach (var sourceField in mappedFields)
|
||||
// Aggiungi i valori dei dati per ogni campo presente nel record
|
||||
foreach (var key in orderedKeys)
|
||||
{
|
||||
if (record.ContainsKey(sourceField))
|
||||
{
|
||||
var value = record[sourceField];
|
||||
var normalizedValue = value?.ToString()?.Trim() ?? "";
|
||||
valuesForHash.Add($"{sourceField}={normalizedValue}");
|
||||
}
|
||||
else
|
||||
{
|
||||
// Se il campo non è presente nel record, aggiungi una stringa vuota
|
||||
valuesForHash.Add($"{sourceField}=");
|
||||
}
|
||||
var value = record[key];
|
||||
var normalizedValue = value?.ToString()?.Trim() ?? "";
|
||||
valuesForHash.Add($"{key}={normalizedValue}");
|
||||
}
|
||||
|
||||
// Combina tutti i valori in una stringa unica
|
||||
@@ -1855,7 +1844,7 @@ public partial class DataCoupler : ComponentBase
|
||||
var hashBytes = sha256.ComputeHash(System.Text.Encoding.UTF8.GetBytes(combinedData));
|
||||
var hashString = Convert.ToHexString(hashBytes);
|
||||
|
||||
Logger.LogDebug("Hash SHA256 generato: {Hash} (include signature mapping)", hashString);
|
||||
Logger.LogDebug("Hash SHA256 generato: {Hash} per {FieldCount} campi", hashString, orderedKeys.Count);
|
||||
return hashString;
|
||||
}
|
||||
}
|
||||
@@ -2578,7 +2567,8 @@ public partial class DataCoupler : ComponentBase
|
||||
|
||||
// Genera la chiave sorgente e l'hash dei dati per questo record (operazioni locali, thread-safe)
|
||||
var sourceKey = GenerateSourceKey(record);
|
||||
var currentDataHash = GenerateDataHash(record);
|
||||
// ✅ Calcola l'hash SOLO sui dati trasformati/mappati che vengono effettivamente trasferiti
|
||||
var currentDataHash = GenerateDataHash(restData);
|
||||
|
||||
// Analizza le associazioni per capire se aggiornare, creare o saltare
|
||||
if (currentUseRecordAssociations && !string.IsNullOrEmpty(sourceKey))
|
||||
@@ -2738,8 +2728,8 @@ public partial class DataCoupler : ComponentBase
|
||||
if (useRecordAssociations && !string.IsNullOrEmpty(transferResult.EntityId))
|
||||
{
|
||||
// IMPORTANTE: Non awaita qui, solo crea il task per esecuzione parallela
|
||||
// Genera l'hash per questo record per salvarlo nell'associazione
|
||||
var dataHashForAssociation = GenerateDataHash(originalData.originalRecord);
|
||||
// Genera l'hash SOLO sui dati trasformati/mappati che sono stati effettivamente trasferiti
|
||||
var dataHashForAssociation = GenerateDataHash(originalData.transformedData);
|
||||
var associationTask = CreateAssociationAsync(originalData.originalRecord, transferResult.EntityId, originalData.recordNumber, dataHashForAssociation);
|
||||
createAssociationTasks.Add(associationTask);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
@page "/scheduling"
|
||||
@using CredentialManager.Models
|
||||
@using CredentialManager.Services
|
||||
@using Data_Coupler.Services
|
||||
|
||||
<PageTitle>Schedulazione Profili</PageTitle>
|
||||
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h3><i class="fas fa-clock"></i> Schedulazione Profili</h3>
|
||||
<div>
|
||||
<a href="/scheduling/history" class="btn btn-outline-info me-2">
|
||||
<i class="fas fa-history"></i> Storico Esecuzioni
|
||||
</a>
|
||||
<button class="btn btn-success" @onclick="ShowCreateModal">
|
||||
<i class="fas fa-plus"></i> Nuova Schedulazione
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (schedules == null)
|
||||
{
|
||||
<div class="d-flex justify-content-center">
|
||||
<div class="spinner-border" role="status">
|
||||
<span class="visually-hidden">Caricamento...</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else if (!schedules.Any())
|
||||
{
|
||||
<div class="alert alert-info">
|
||||
<i class="fas fa-info-circle"></i> Nessuna schedulazione configurata.
|
||||
<button class="btn btn-link p-0 ms-2" @onclick="ShowCreateModal">
|
||||
Crea la prima schedulazione
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="row">
|
||||
@foreach (var schedule in schedules.OrderBy(s => s.NextExecutionTime))
|
||||
{
|
||||
<div class="col-md-6 col-lg-4 mb-3">
|
||||
<div class="card h-100 @(schedule.IsEnabled ? "border-success" : "border-secondary")">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h6 class="mb-0">
|
||||
<i class="fas fa-@(schedule.ScheduleType switch { "once" => "clock", "interval" => "redo", "daily" => "calendar-day", "weekly" => "calendar-week", "monthly" => "calendar", _ => "clock" })"></i>
|
||||
@schedule.Name
|
||||
</h6>
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-sm btn-outline-secondary dropdown-toggle" data-bs-toggle="dropdown">
|
||||
<i class="fas fa-ellipsis-v"></i>
|
||||
</button>
|
||||
<ul class="dropdown-menu">
|
||||
<li><button class="dropdown-item" @onclick="() => ShowEditModal(schedule)">
|
||||
<i class="fas fa-edit"></i> Modifica
|
||||
</button></li>
|
||||
<li><button class="dropdown-item" @onclick="() => ExecuteScheduleManually(schedule.Id)">
|
||||
<i class="fas fa-play"></i> Esegui Ora
|
||||
</button></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><button class="dropdown-item text-danger" @onclick="() => DeleteSchedule(schedule.Id)">
|
||||
<i class="fas fa-trash"></i> Elimina
|
||||
</button></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="card-text text-muted mb-2">
|
||||
<strong>Profilo:</strong> @schedule.Profile?.Name
|
||||
</p>
|
||||
|
||||
@if (!string.IsNullOrEmpty(schedule.Description))
|
||||
{
|
||||
<p class="card-text small">@schedule.Description</p>
|
||||
}
|
||||
|
||||
<div class="mb-2">
|
||||
<small class="text-muted">
|
||||
<strong>Tipo:</strong> @schedule.GetScheduleDescription()
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<!-- Prossima esecuzione -->
|
||||
@if (schedule.NextExecutionTime.HasValue)
|
||||
{
|
||||
<div class="mb-2">
|
||||
<small class="text-info">
|
||||
<i class="fas fa-clock"></i>
|
||||
<strong>Prossima esecuzione:</strong><br>
|
||||
@schedule.NextExecutionTime.Value.ToString("dd/MM/yyyy HH:mm")
|
||||
</small>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Ultima esecuzione -->
|
||||
@if (schedule.LastExecutionTime.HasValue)
|
||||
{
|
||||
<div class="mb-2">
|
||||
<small class="text-muted">
|
||||
<i class="fas fa-history"></i>
|
||||
<strong>Ultima esecuzione:</strong><br>
|
||||
@schedule.LastExecutionTime.Value.ToString("dd/MM/yyyy HH:mm")
|
||||
</small>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Status ultima esecuzione -->
|
||||
@if (!string.IsNullOrEmpty(schedule.LastExecutionStatus))
|
||||
{
|
||||
<div class="mb-2">
|
||||
<span class="badge bg-@(schedule.LastExecutionStatus switch { "success" => "success", "failed" => "danger", "running" => "primary", _ => "secondary" })">
|
||||
@schedule.LastExecutionStatus.ToUpper()
|
||||
@if (schedule.LastExecutionRecordCount.HasValue)
|
||||
{
|
||||
<text> (@schedule.LastExecutionRecordCount record)</text>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-between align-items-center">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox"
|
||||
checked="@schedule.IsEnabled"
|
||||
@onchange="(e) => ToggleScheduleEnabled(schedule.Id, (bool)e.Value!)">
|
||||
<label class="form-check-label small">
|
||||
@(schedule.IsEnabled ? "Attiva" : "Disattivata")
|
||||
</label>
|
||||
</div>
|
||||
<small class="text-muted">
|
||||
Esecuzioni: @schedule.ExecutionCount
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal per creazione/modifica schedulazione -->
|
||||
<div class="modal fade" id="scheduleModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">
|
||||
@(editingSchedule?.Id > 0 ? "Modifica Schedulazione" : "Nuova Schedulazione")
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
@if (editingSchedule != null)
|
||||
{
|
||||
<EditForm Model="editingSchedule" OnValidSubmit="SaveSchedule">
|
||||
<DataAnnotationsValidator />
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Nome *</label>
|
||||
<InputText @bind-Value="editingSchedule.Name" class="form-control" />
|
||||
<ValidationMessage For="() => editingSchedule.Name" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Profilo da eseguire *</label>
|
||||
<InputSelect @bind-Value="editingSchedule.ProfileId" class="form-select" @onchange="OnProfileSelectionChanged">
|
||||
<option value="0">-- Seleziona Profilo --</option>
|
||||
@if (availableProfiles != null)
|
||||
{
|
||||
@foreach (var profile in availableProfiles.Where(p => p.SourceType != "file"))
|
||||
{
|
||||
<option value="@profile.Id">@profile.Name</option>
|
||||
}
|
||||
}
|
||||
</InputSelect>
|
||||
<ValidationMessage For="() => editingSchedule.ProfileId" />
|
||||
@if (availableProfiles?.Any(p => p.SourceType == "file") == true)
|
||||
{
|
||||
<small class="form-text text-muted">
|
||||
⚠️ I profili con file come sorgente sono esclusi dalle schedulazioni per motivi di sicurezza.
|
||||
</small>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Descrizione</label>
|
||||
<InputTextArea @bind-Value="editingSchedule.Description" class="form-control" rows="2" />
|
||||
</div>
|
||||
|
||||
@* Sezione override database *@
|
||||
@if (selectedProfile != null && (selectedProfile.SourceType == "database" || selectedProfile.DestinationType == "database"))
|
||||
{
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">
|
||||
<i class="fas fa-database"></i> Override Database
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
@if (selectedProfile.SourceType == "database")
|
||||
{
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Database Sorgente (Opzionale)</label>
|
||||
<InputText @bind-Value="editingSchedule.SourceDatabaseOverride" class="form-control" />
|
||||
<small class="form-text text-muted">
|
||||
Lascia vuoto per usare il database specificato nella credenziale.
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@if (selectedProfile.DestinationType == "database")
|
||||
{
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Database Destinazione (Opzionale)</label>
|
||||
<InputText @bind-Value="editingSchedule.DestinationDatabaseOverride" class="form-control" />
|
||||
<small class="form-text text-muted">
|
||||
Lascia vuoto per usare il database specificato nella credenziale.
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Tipo di Schedulazione *</label>
|
||||
<InputSelect @bind-Value="editingSchedule.ScheduleType" class="form-select" @onchange="OnScheduleTypeChanged">
|
||||
<option value="">-- Seleziona Tipo --</option>
|
||||
<option value="once">Una volta</option>
|
||||
<option value="interval">Intervallo Personalizzato</option>
|
||||
<option value="daily">Giornaliera</option>
|
||||
<option value="weekly">Settimanale</option>
|
||||
<option value="monthly">Mensile</option>
|
||||
</InputSelect>
|
||||
<ValidationMessage For="() => editingSchedule.ScheduleType" />
|
||||
</div>
|
||||
|
||||
@if (editingSchedule.ScheduleType == "once")
|
||||
{
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Data e Ora di Esecuzione *</label>
|
||||
<input type="datetime-local" @bind="editingSchedule.ScheduledDateTime" class="form-control" />
|
||||
</div>
|
||||
}
|
||||
else if (editingSchedule.ScheduleType == "interval")
|
||||
{
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Intervallo *</label>
|
||||
<InputNumber @bind-Value="editingSchedule.IntervalValue" class="form-control" min="1" />
|
||||
<small class="form-text text-muted">Numero di unità temporali</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Unità di Tempo *</label>
|
||||
<InputSelect @bind-Value="editingSchedule.IntervalUnit" class="form-select">
|
||||
<option value="">-- Seleziona Unità --</option>
|
||||
<option value="seconds">Secondi</option>
|
||||
<option value="minutes">Minuti</option>
|
||||
<option value="hours">Ore</option>
|
||||
<option value="days">Giorni</option>
|
||||
<option value="weeks">Settimane</option>
|
||||
<option value="months">Mesi</option>
|
||||
</InputSelect>
|
||||
<small class="form-text text-muted">Frequenza di ripetizione</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="alert alert-info">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
<strong>Anteprima:</strong> @GetIntervalPreview()
|
||||
</div>
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(editingSchedule.ScheduleType) && editingSchedule.ScheduleType != "once" && editingSchedule.ScheduleType != "interval")
|
||||
{
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Ora di Esecuzione *</label>
|
||||
<InputText @bind-Value="editingSchedule.DailyTime" class="form-control" placeholder="HH:mm" />
|
||||
<small class="form-text text-muted">Formato 24 ore (es: 14:30)</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (editingSchedule.ScheduleType == "weekly")
|
||||
{
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Giorno della Settimana *</label>
|
||||
<InputSelect @bind-Value="editingSchedule.DayOfWeek" class="form-select">
|
||||
<option value="">-- Seleziona --</option>
|
||||
<option value="0">Domenica</option>
|
||||
<option value="1">Lunedì</option>
|
||||
<option value="2">Martedì</option>
|
||||
<option value="3">Mercoledì</option>
|
||||
<option value="4">Giovedì</option>
|
||||
<option value="5">Venerdì</option>
|
||||
<option value="6">Sabato</option>
|
||||
</InputSelect>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else if (editingSchedule.ScheduleType == "monthly")
|
||||
{
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Giorno del Mese *</label>
|
||||
<InputNumber @bind-Value="editingSchedule.DayOfMonth" class="form-control" min="1" max="31" />
|
||||
<small class="form-text text-muted">1-31</small>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="form-check mb-3">
|
||||
<InputCheckbox @bind-Value="editingSchedule.IsEnabled" class="form-check-input" />
|
||||
<label class="form-check-label">
|
||||
Schedulazione attiva
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-end gap-2">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annulla</button>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-save"></i> Salva
|
||||
</button>
|
||||
</div>
|
||||
</EditForm>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,444 @@
|
||||
using CredentialManager.Models;
|
||||
using CredentialManager.Services;
|
||||
using Data_Coupler.Services;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.JSInterop;
|
||||
|
||||
namespace Data_Coupler.Pages;
|
||||
|
||||
public partial class Scheduling : ComponentBase
|
||||
{
|
||||
[Inject] private IProfileScheduleService ScheduleService { get; set; } = null!;
|
||||
[Inject] private IDataCouplerProfileService ProfileService { get; set; } = null!;
|
||||
[Inject] private IDataTransferService DataTransferService { get; set; } = null!;
|
||||
[Inject] private IJSRuntime JSRuntime { get; set; } = null!;
|
||||
[Inject] private ILogger<Scheduling> Logger { get; set; } = null!;
|
||||
|
||||
protected List<ProfileSchedule>? schedules;
|
||||
protected List<DataCouplerProfile>? availableProfiles;
|
||||
protected ProfileSchedule? editingSchedule;
|
||||
protected DataCouplerProfile? selectedProfile;
|
||||
protected bool isExecuting = false;
|
||||
protected List<ScheduleExecutionHistory>? executionHistory;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await LoadSchedules();
|
||||
await LoadAvailableProfiles();
|
||||
}
|
||||
|
||||
protected async Task LoadSchedules()
|
||||
{
|
||||
try
|
||||
{
|
||||
schedules = await ScheduleService.GetAllSchedulesAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Errore nel caricamento delle schedulazioni");
|
||||
await ShowErrorMessage("Errore nel caricamento delle schedulazioni: " + ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
protected async Task LoadAvailableProfiles()
|
||||
{
|
||||
try
|
||||
{
|
||||
availableProfiles = await ScheduleService.GetAvailableProfilesAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Errore nel caricamento dei profili disponibili");
|
||||
}
|
||||
}
|
||||
|
||||
protected async Task ShowCreateModal()
|
||||
{
|
||||
editingSchedule = new ProfileSchedule
|
||||
{
|
||||
Name = "",
|
||||
IsEnabled = true,
|
||||
ScheduleType = "",
|
||||
DailyTime = "09:00",
|
||||
IntervalValue = 5,
|
||||
IntervalUnit = "minutes"
|
||||
};
|
||||
|
||||
selectedProfile = null;
|
||||
await ShowModal();
|
||||
}
|
||||
|
||||
protected async Task ShowEditModal(ProfileSchedule schedule)
|
||||
{
|
||||
editingSchedule = new ProfileSchedule
|
||||
{
|
||||
Id = schedule.Id,
|
||||
Name = schedule.Name,
|
||||
Description = schedule.Description,
|
||||
ProfileId = schedule.ProfileId,
|
||||
IsEnabled = schedule.IsEnabled,
|
||||
ScheduleType = schedule.ScheduleType,
|
||||
ScheduledDateTime = schedule.ScheduledDateTime,
|
||||
DailyTime = schedule.DailyTime,
|
||||
DayOfWeek = schedule.DayOfWeek,
|
||||
DayOfMonth = schedule.DayOfMonth,
|
||||
IntervalValue = schedule.IntervalValue,
|
||||
IntervalUnit = schedule.IntervalUnit,
|
||||
SourceDatabaseOverride = schedule.SourceDatabaseOverride,
|
||||
DestinationDatabaseOverride = schedule.DestinationDatabaseOverride
|
||||
};
|
||||
|
||||
// Imposta il profilo selezionato per mostrare i campi di override
|
||||
selectedProfile = availableProfiles?.FirstOrDefault(p => p.Id == schedule.ProfileId);
|
||||
|
||||
await ShowModal();
|
||||
}
|
||||
|
||||
protected async Task ShowModal()
|
||||
{
|
||||
StateHasChanged();
|
||||
await Task.Delay(100);
|
||||
|
||||
try
|
||||
{
|
||||
// Proviamo prima con l'approccio Bootstrap standard
|
||||
await JSRuntime.InvokeVoidAsync("eval",
|
||||
"if (typeof bootstrap !== 'undefined' && bootstrap.Modal) { " +
|
||||
"var modal = new bootstrap.Modal(document.getElementById('scheduleModal')); " +
|
||||
"modal.show(); " +
|
||||
"} else { " +
|
||||
"document.getElementById('scheduleModal').style.display = 'block'; " +
|
||||
"document.getElementById('scheduleModal').classList.add('show'); " +
|
||||
"}");
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Fallback: mostra il modal manualmente
|
||||
await JSRuntime.InvokeVoidAsync("eval",
|
||||
"var modal = document.getElementById('scheduleModal');" +
|
||||
"modal.style.display = 'block';" +
|
||||
"modal.classList.add('show');" +
|
||||
"document.body.classList.add('modal-open');" +
|
||||
"var backdrop = document.createElement('div');" +
|
||||
"backdrop.className = 'modal-backdrop fade show';" +
|
||||
"document.body.appendChild(backdrop);");
|
||||
}
|
||||
}
|
||||
|
||||
protected async Task HideModal()
|
||||
{
|
||||
try
|
||||
{
|
||||
await JSRuntime.InvokeVoidAsync("eval",
|
||||
"if (typeof bootstrap !== 'undefined' && bootstrap.Modal) { " +
|
||||
"var modalElement = document.getElementById('scheduleModal'); " +
|
||||
"var modal = bootstrap.Modal.getInstance(modalElement); " +
|
||||
"if (modal) modal.hide(); " +
|
||||
"} else { " +
|
||||
"document.getElementById('scheduleModal').style.display = 'none'; " +
|
||||
"document.getElementById('scheduleModal').classList.remove('show'); " +
|
||||
"document.body.classList.remove('modal-open'); " +
|
||||
"var backdrop = document.querySelector('.modal-backdrop'); " +
|
||||
"if (backdrop) backdrop.remove(); " +
|
||||
"}");
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Fallback: nascondi il modal manualmente
|
||||
await JSRuntime.InvokeVoidAsync("eval",
|
||||
"var modal = document.getElementById('scheduleModal');" +
|
||||
"modal.style.display = 'none';" +
|
||||
"modal.classList.remove('show');" +
|
||||
"document.body.classList.remove('modal-open');" +
|
||||
"var backdrop = document.querySelector('.modal-backdrop');" +
|
||||
"if (backdrop) backdrop.remove();");
|
||||
}
|
||||
}
|
||||
|
||||
protected async Task SaveSchedule()
|
||||
{
|
||||
if (editingSchedule == null) return;
|
||||
|
||||
try
|
||||
{
|
||||
if (editingSchedule.Id == 0)
|
||||
{
|
||||
await ScheduleService.CreateScheduleAsync(editingSchedule);
|
||||
await ShowSuccessMessage("Schedulazione creata con successo!");
|
||||
}
|
||||
else
|
||||
{
|
||||
await ScheduleService.UpdateScheduleAsync(editingSchedule);
|
||||
await ShowSuccessMessage("Schedulazione aggiornata con successo!");
|
||||
}
|
||||
|
||||
await CloseModal();
|
||||
await LoadSchedules();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Errore nel salvataggio della schedulazione");
|
||||
await ShowErrorMessage("Errore nel salvataggio: " + ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
protected async Task DeleteSchedule(int scheduleId)
|
||||
{
|
||||
var confirmed = await JSRuntime.InvokeAsync<bool>("confirm", "Sei sicuro di voler eliminare questa schedulazione?");
|
||||
if (!confirmed) return;
|
||||
|
||||
try
|
||||
{
|
||||
var success = await ScheduleService.DeleteScheduleAsync(scheduleId);
|
||||
if (success)
|
||||
{
|
||||
await ShowSuccessMessage("Schedulazione eliminata con successo!");
|
||||
await LoadSchedules();
|
||||
}
|
||||
else
|
||||
{
|
||||
await ShowErrorMessage("Schedulazione non trovata.");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Errore nell'eliminazione della schedulazione {ScheduleId}", scheduleId);
|
||||
await ShowErrorMessage("Errore nell'eliminazione: " + ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
protected async Task ToggleScheduleEnabled(int scheduleId, bool enabled)
|
||||
{
|
||||
try
|
||||
{
|
||||
var schedule = schedules?.FirstOrDefault(s => s.Id == scheduleId);
|
||||
if (schedule != null)
|
||||
{
|
||||
schedule.IsEnabled = enabled;
|
||||
await ScheduleService.UpdateScheduleAsync(schedule);
|
||||
|
||||
Logger.LogInformation("Schedulazione {ScheduleId} {Status}", scheduleId, enabled ? "attivata" : "disattivata");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Errore nel cambio stato schedulazione {ScheduleId}", scheduleId);
|
||||
await ShowErrorMessage("Errore nel cambio stato: " + ex.Message);
|
||||
await LoadSchedules(); // Ricarica per ripristinare lo stato
|
||||
}
|
||||
}
|
||||
|
||||
protected async Task ExecuteScheduleManually(int scheduleId)
|
||||
{
|
||||
if (isExecuting) return;
|
||||
|
||||
var confirmed = await JSRuntime.InvokeAsync<bool>("confirm", "Eseguire immediatamente questa schedulazione?");
|
||||
if (!confirmed) return;
|
||||
|
||||
isExecuting = true;
|
||||
ScheduleExecutionHistory? executionHistory = null;
|
||||
StateHasChanged();
|
||||
|
||||
try
|
||||
{
|
||||
var schedule = schedules?.FirstOrDefault(s => s.Id == scheduleId);
|
||||
if (schedule?.Profile == null)
|
||||
{
|
||||
await ShowErrorMessage("Schedulazione o profilo non trovato.");
|
||||
return;
|
||||
}
|
||||
|
||||
Logger.LogInformation("Esecuzione manuale schedulazione {ScheduleId} - {ScheduleName}", scheduleId, schedule.Name);
|
||||
|
||||
// Crea record nello storico
|
||||
executionHistory = new ScheduleExecutionHistory
|
||||
{
|
||||
ScheduleId = scheduleId,
|
||||
ProfileId = schedule.ProfileId,
|
||||
ProfileName = schedule.Profile.Name,
|
||||
StartTime = DateTime.Now,
|
||||
Status = "running",
|
||||
TriggerType = "manual",
|
||||
TriggeredBy = Environment.UserName,
|
||||
SourceType = schedule.Profile.SourceType,
|
||||
DestinationType = schedule.Profile.DestinationType,
|
||||
SourceInfo = schedule.SourceDatabaseOverride != null ? $"Database Override: {schedule.SourceDatabaseOverride}" : null,
|
||||
DestinationInfo = schedule.DestinationDatabaseOverride != null ? $"Database Override: {schedule.DestinationDatabaseOverride}" : null,
|
||||
Message = "Esecuzione manuale avviata"
|
||||
};
|
||||
|
||||
executionHistory = await ScheduleService.CreateExecutionHistoryAsync(executionHistory);
|
||||
|
||||
// Aggiorna lo status della schedulazione
|
||||
await ScheduleService.UpdateExecutionStatusAsync(scheduleId, "running", "Esecuzione manuale avviata");
|
||||
await LoadSchedules();
|
||||
|
||||
// Esegui il trasferimento dati con override database se specificati
|
||||
var result = await DataTransferService.ExecuteProfileAsync(
|
||||
schedule.Profile,
|
||||
schedule.SourceDatabaseOverride,
|
||||
schedule.DestinationDatabaseOverride);
|
||||
|
||||
// Aggiorna lo storico con il risultato
|
||||
executionHistory.EndTime = DateTime.Now;
|
||||
executionHistory.Status = result.IsSuccess ? "success" : "failed";
|
||||
executionHistory.RecordsProcessed = result.RecordsProcessed;
|
||||
executionHistory.Message = result.IsSuccess
|
||||
? $"Esecuzione completata con successo. {result.RecordsProcessed} record elaborati in {result.Duration.TotalSeconds:F2} secondi."
|
||||
: $"Esecuzione fallita: {result.ErrorMessage}";
|
||||
|
||||
if (!result.IsSuccess)
|
||||
{
|
||||
executionHistory.ErrorDetails = string.Join(Environment.NewLine, result.ErrorDetails);
|
||||
}
|
||||
|
||||
// Aggiungi informazioni aggiuntive se disponibili
|
||||
if (result.AdditionalInfo.Any())
|
||||
{
|
||||
executionHistory.AdditionalInfo = System.Text.Json.JsonSerializer.Serialize(result.AdditionalInfo);
|
||||
}
|
||||
|
||||
await ScheduleService.UpdateExecutionHistoryAsync(executionHistory);
|
||||
|
||||
// Aggiorna lo status della schedulazione
|
||||
var status = result.IsSuccess ? "success" : "failed";
|
||||
var message = result.IsSuccess
|
||||
? $"Esecuzione completata con successo. {result.RecordsProcessed} record elaborati."
|
||||
: $"Esecuzione fallita: {result.ErrorMessage}";
|
||||
|
||||
await ScheduleService.UpdateExecutionStatusAsync(scheduleId, status, message, result.RecordsProcessed);
|
||||
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
await ShowSuccessMessage($"Schedulazione eseguita con successo! {result.RecordsProcessed} record elaborati in {result.Duration.TotalSeconds:F2} secondi.");
|
||||
}
|
||||
else
|
||||
{
|
||||
await ShowErrorMessage($"Errore durante l'esecuzione: {result.ErrorMessage}");
|
||||
}
|
||||
|
||||
await LoadSchedules();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Errore nell'esecuzione manuale schedulazione {ScheduleId}", scheduleId);
|
||||
|
||||
// Aggiorna lo storico in caso di eccezione
|
||||
if (executionHistory != null)
|
||||
{
|
||||
executionHistory.EndTime = DateTime.Now;
|
||||
executionHistory.Status = "failed";
|
||||
executionHistory.Message = $"Errore durante l'esecuzione: {ex.Message}";
|
||||
executionHistory.ErrorDetails = ex.ToString();
|
||||
await ScheduleService.UpdateExecutionHistoryAsync(executionHistory);
|
||||
}
|
||||
|
||||
await ScheduleService.UpdateExecutionStatusAsync(scheduleId, "failed", $"Errore: {ex.Message}");
|
||||
await ShowErrorMessage("Errore nell'esecuzione: " + ex.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
isExecuting = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
protected void OnProfileSelectionChanged(ChangeEventArgs e)
|
||||
{
|
||||
if (editingSchedule != null && int.TryParse(e.Value?.ToString(), out int profileId))
|
||||
{
|
||||
editingSchedule.ProfileId = profileId;
|
||||
selectedProfile = availableProfiles?.FirstOrDefault(p => p.Id == profileId);
|
||||
|
||||
// Reset override database quando cambia profilo
|
||||
editingSchedule.SourceDatabaseOverride = null;
|
||||
editingSchedule.DestinationDatabaseOverride = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
selectedProfile = null;
|
||||
}
|
||||
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
protected void OnScheduleTypeChanged(ChangeEventArgs e)
|
||||
{
|
||||
if (editingSchedule != null)
|
||||
{
|
||||
editingSchedule.ScheduleType = e.Value?.ToString() ?? "";
|
||||
|
||||
// Reset campi quando cambia il tipo
|
||||
if (editingSchedule.ScheduleType != "once")
|
||||
{
|
||||
editingSchedule.ScheduledDateTime = null;
|
||||
}
|
||||
|
||||
if (editingSchedule.ScheduleType != "weekly")
|
||||
{
|
||||
editingSchedule.DayOfWeek = null;
|
||||
}
|
||||
|
||||
if (editingSchedule.ScheduleType != "monthly")
|
||||
{
|
||||
editingSchedule.DayOfMonth = null;
|
||||
}
|
||||
|
||||
if (editingSchedule.ScheduleType != "interval")
|
||||
{
|
||||
editingSchedule.IntervalValue = null;
|
||||
editingSchedule.IntervalUnit = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Imposta valori predefiniti per interval
|
||||
editingSchedule.IntervalValue ??= 5;
|
||||
editingSchedule.IntervalUnit ??= "minutes";
|
||||
}
|
||||
}
|
||||
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
protected string GetIntervalPreview()
|
||||
{
|
||||
if (editingSchedule == null || !editingSchedule.IntervalValue.HasValue || string.IsNullOrEmpty(editingSchedule.IntervalUnit))
|
||||
{
|
||||
return "Configura intervallo e unità di tempo";
|
||||
}
|
||||
|
||||
var value = editingSchedule.IntervalValue.Value;
|
||||
var unit = editingSchedule.IntervalUnit;
|
||||
|
||||
var unitName = unit switch
|
||||
{
|
||||
"seconds" => value == 1 ? "secondo" : "secondi",
|
||||
"minutes" => value == 1 ? "minuto" : "minuti",
|
||||
"hours" => value == 1 ? "ora" : "ore",
|
||||
"days" => value == 1 ? "giorno" : "giorni",
|
||||
"weeks" => value == 1 ? "settimana" : "settimane",
|
||||
"months" => value == 1 ? "mese" : "mesi",
|
||||
_ => unit
|
||||
};
|
||||
|
||||
return $"Esecuzione ogni {value} {unitName}";
|
||||
}
|
||||
|
||||
private async Task CloseModal()
|
||||
{
|
||||
await HideModal();
|
||||
editingSchedule = null;
|
||||
selectedProfile = null;
|
||||
}
|
||||
|
||||
private async Task ShowSuccessMessage(string message)
|
||||
{
|
||||
await JSRuntime.InvokeVoidAsync("alert", message);
|
||||
}
|
||||
|
||||
private async Task ShowErrorMessage(string message)
|
||||
{
|
||||
await JSRuntime.InvokeVoidAsync("alert", "Errore: " + message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
@page "/scheduling/history"
|
||||
@using CredentialManager.Models
|
||||
@using CredentialManager.Services
|
||||
|
||||
<PageTitle>Storico Esecuzioni</PageTitle>
|
||||
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h3><i class="fas fa-history"></i> Storico Esecuzioni Schedulazioni</h3>
|
||||
<div>
|
||||
<button class="btn btn-outline-secondary me-2" @onclick="LoadHistory">
|
||||
<i class="fas fa-sync-alt"></i> Aggiorna
|
||||
</button>
|
||||
<a href="/scheduling" class="btn btn-primary">
|
||||
<i class="fas fa-arrow-left"></i> Torna alle Schedulazioni
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (executionHistory == null)
|
||||
{
|
||||
<div class="d-flex justify-content-center">
|
||||
<div class="spinner-border" role="status">
|
||||
<span class="visually-hidden">Caricamento...</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else if (!executionHistory.Any())
|
||||
{
|
||||
<div class="alert alert-info">
|
||||
<i class="fas fa-info-circle"></i> Nessuna esecuzione trovata nello storico.
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<div class="card bg-light">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title">
|
||||
<i class="fas fa-chart-bar"></i> Statistiche
|
||||
</h6>
|
||||
<div class="row text-center">
|
||||
<div class="col-4">
|
||||
<div class="fs-5 text-success">@executionHistory.Count(e => e.Status == "success")</div>
|
||||
<small class="text-muted">Successo</small>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<div class="fs-5 text-danger">@executionHistory.Count(e => e.Status == "failed")</div>
|
||||
<small class="text-muted">Fallite</small>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<div class="fs-5 text-primary">@executionHistory.Count(e => e.Status == "running")</div>
|
||||
<small class="text-muted">In corso</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card bg-light">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title">
|
||||
<i class="fas fa-database"></i> Record Processati
|
||||
</h6>
|
||||
<div class="text-center">
|
||||
<div class="fs-4 text-info">@executionHistory.Where(e => e.Status == "success").Sum(e => e.RecordsProcessed)</div>
|
||||
<small class="text-muted">Totale record elaborati con successo</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-hover">
|
||||
<thead class="table-dark">
|
||||
<tr>
|
||||
<th>Data/Ora Inizio</th>
|
||||
<th>Profilo</th>
|
||||
<th>Schedulazione</th>
|
||||
<th>Durata</th>
|
||||
<th>Status</th>
|
||||
<th>Record</th>
|
||||
<th>Trigger</th>
|
||||
<th>Azioni</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var execution in executionHistory.OrderByDescending(e => e.StartTime))
|
||||
{
|
||||
<tr class="@(execution.Status == "success" ? "table-success" : execution.Status == "failed" ? "table-danger" : execution.Status == "running" ? "table-info" : "")">
|
||||
<td>
|
||||
<div>@execution.StartTime.ToString("dd/MM/yyyy HH:mm:ss")</div>
|
||||
@if (execution.EndTime.HasValue)
|
||||
{
|
||||
<small class="text-muted">Fine: @execution.EndTime.Value.ToString("HH:mm:ss")</small>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<div class="fw-bold">@execution.ProfileName</div>
|
||||
@if (!string.IsNullOrEmpty(execution.SourceInfo) || !string.IsNullOrEmpty(execution.DestinationInfo))
|
||||
{
|
||||
<small class="text-muted">
|
||||
@if (!string.IsNullOrEmpty(execution.SourceInfo))
|
||||
{
|
||||
<div>S: @execution.SourceInfo</div>
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(execution.DestinationInfo))
|
||||
{
|
||||
<div>D: @execution.DestinationInfo</div>
|
||||
}
|
||||
</small>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<div>@execution.Schedule?.Name</div>
|
||||
<small class="text-muted">ID: @execution.ScheduleId</small>
|
||||
</td>
|
||||
<td>
|
||||
@if (execution.Duration.HasValue)
|
||||
{
|
||||
<span class="badge bg-secondary">@FormatDuration(execution.Duration.Value)</span>
|
||||
}
|
||||
else if (execution.Status == "running")
|
||||
{
|
||||
<span class="badge bg-primary">In corso...</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted">-</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-@(execution.Status switch { "success" => "success", "failed" => "danger", "running" => "primary", "cancelled" => "warning", _ => "secondary" })">
|
||||
@execution.GetStatusDisplayText()
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="fw-bold">@execution.RecordsProcessed</div>
|
||||
@if (execution.RecordsWithErrors.HasValue && execution.RecordsWithErrors.Value > 0)
|
||||
{
|
||||
<small class="text-warning">@execution.RecordsWithErrors errori</small>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<div>
|
||||
<span class="badge bg-@(execution.TriggerType == "manual" ? "info" : "success")">
|
||||
@execution.TriggerType.ToUpper()
|
||||
</span>
|
||||
</div>
|
||||
@if (!string.IsNullOrEmpty(execution.TriggeredBy))
|
||||
{
|
||||
<small class="text-muted">@execution.TriggeredBy</small>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-outline-info" @onclick="() => ShowExecutionDetails(execution)">
|
||||
<i class="fas fa-eye"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal per dettagli esecuzione -->
|
||||
<div class="modal fade" id="executionDetailModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-xl">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">
|
||||
<i class="fas fa-info-circle"></i> Dettagli Esecuzione
|
||||
@if (selectedExecution != null)
|
||||
{
|
||||
<span> - @selectedExecution.ProfileName</span>
|
||||
}
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
@if (selectedExecution != null)
|
||||
{
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h6>Informazioni Generali</h6>
|
||||
<table class="table table-sm">
|
||||
<tr><th>Data Inizio:</th><td>@selectedExecution.StartTime.ToString("dd/MM/yyyy HH:mm:ss")</td></tr>
|
||||
<tr><th>Data Fine:</th><td>@(selectedExecution.EndTime?.ToString("dd/MM/yyyy HH:mm:ss") ?? "In corso")</td></tr>
|
||||
<tr><th>Durata:</th><td>@(selectedExecution.Duration?.ToString(@"hh\:mm\:ss") ?? "N/A")</td></tr>
|
||||
<tr><th>Status:</th><td><span class="badge bg-@(selectedExecution.Status switch { "success" => "success", "failed" => "danger", "running" => "primary", "cancelled" => "warning", _ => "secondary" })">@selectedExecution.GetStatusDisplayText()</span></td></tr>
|
||||
<tr><th>Trigger:</th><td>@selectedExecution.TriggerType (@selectedExecution.TriggeredBy)</td></tr>
|
||||
<tr><th>Record Processati:</th><td>@selectedExecution.RecordsProcessed</td></tr>
|
||||
@if (selectedExecution.RecordsWithErrors.HasValue)
|
||||
{
|
||||
<tr><th>Record con Errori:</th><td class="text-warning">@selectedExecution.RecordsWithErrors</td></tr>
|
||||
}
|
||||
</table>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<h6>Configurazione</h6>
|
||||
<table class="table table-sm">
|
||||
<tr><th>Schedulazione:</th><td>@selectedExecution.Schedule?.Name</td></tr>
|
||||
<tr><th>Profilo:</th><td>@selectedExecution.ProfileName (ID: @selectedExecution.ProfileId)</td></tr>
|
||||
<tr><th>Tipo Sorgente:</th><td>@selectedExecution.SourceType</td></tr>
|
||||
<tr><th>Tipo Destinazione:</th><td>@selectedExecution.DestinationType</td></tr>
|
||||
@if (!string.IsNullOrEmpty(selectedExecution.SourceInfo))
|
||||
{
|
||||
<tr><th>Info Sorgente:</th><td>@selectedExecution.SourceInfo</td></tr>
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(selectedExecution.DestinationInfo))
|
||||
{
|
||||
<tr><th>Info Destinazione:</th><td>@selectedExecution.DestinationInfo</td></tr>
|
||||
}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrEmpty(selectedExecution.Message))
|
||||
{
|
||||
<h6>Messaggio</h6>
|
||||
<div class="alert alert-@(selectedExecution.Status == "success" ? "success" : selectedExecution.Status == "failed" ? "danger" : "info")">
|
||||
@selectedExecution.Message
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrEmpty(selectedExecution.ErrorDetails))
|
||||
{
|
||||
<h6>Dettagli Errori</h6>
|
||||
<div class="alert alert-danger">
|
||||
<pre style="white-space: pre-wrap; font-size: 0.85em;">@selectedExecution.ErrorDetails</pre>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrEmpty(selectedExecution.AdditionalInfo))
|
||||
{
|
||||
<h6>Informazioni Aggiuntive</h6>
|
||||
<div class="alert alert-light">
|
||||
<pre style="white-space: pre-wrap; font-size: 0.85em;">@selectedExecution.AdditionalInfo</pre>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Chiudi</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,152 @@
|
||||
using CredentialManager.Models;
|
||||
using CredentialManager.Services;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.JSInterop;
|
||||
|
||||
namespace Data_Coupler.Pages;
|
||||
|
||||
public partial class SchedulingHistory : ComponentBase
|
||||
{
|
||||
[Inject] private IProfileScheduleService ScheduleService { get; set; } = null!;
|
||||
[Inject] private IJSRuntime JSRuntime { get; set; } = null!;
|
||||
[Inject] private ILogger<SchedulingHistory> Logger { get; set; } = null!;
|
||||
|
||||
protected List<ScheduleExecutionHistory>? executionHistory;
|
||||
protected ScheduleExecutionHistory? selectedExecution;
|
||||
protected bool isLoading = false;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await LoadHistory();
|
||||
}
|
||||
|
||||
protected async Task LoadHistory()
|
||||
{
|
||||
if (isLoading) return;
|
||||
|
||||
isLoading = true;
|
||||
StateHasChanged();
|
||||
|
||||
try
|
||||
{
|
||||
// Carica le ultime 100 esecuzioni
|
||||
executionHistory = await ScheduleService.GetRecentExecutionsAsync(100);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Errore nel caricamento dello storico delle esecuzioni");
|
||||
await ShowErrorMessage("Errore nel caricamento dello storico: " + ex.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
isLoading = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
protected async Task ShowExecutionDetails(ScheduleExecutionHistory execution)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Carica i dettagli completi dell'esecuzione
|
||||
selectedExecution = await ScheduleService.GetExecutionByIdAsync(execution.Id);
|
||||
|
||||
if (selectedExecution != null)
|
||||
{
|
||||
await ShowModal();
|
||||
}
|
||||
else
|
||||
{
|
||||
await ShowErrorMessage("Dettagli dell'esecuzione non trovati.");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Errore nel caricamento dei dettagli esecuzione {ExecutionId}", execution.Id);
|
||||
await ShowErrorMessage("Errore nel caricamento dei dettagli: " + ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
protected async Task ShowModal()
|
||||
{
|
||||
StateHasChanged();
|
||||
await Task.Delay(100);
|
||||
|
||||
try
|
||||
{
|
||||
// Proviamo prima con l'approccio Bootstrap standard
|
||||
await JSRuntime.InvokeVoidAsync("eval",
|
||||
"if (typeof bootstrap !== 'undefined' && bootstrap.Modal) { " +
|
||||
"var modal = new bootstrap.Modal(document.getElementById('executionDetailModal')); " +
|
||||
"modal.show(); " +
|
||||
"} else { " +
|
||||
"document.getElementById('executionDetailModal').style.display = 'block'; " +
|
||||
"document.getElementById('executionDetailModal').classList.add('show'); " +
|
||||
"}");
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Fallback: mostra il modal manualmente
|
||||
await JSRuntime.InvokeVoidAsync("eval",
|
||||
"var modal = document.getElementById('executionDetailModal');" +
|
||||
"modal.style.display = 'block';" +
|
||||
"modal.classList.add('show');" +
|
||||
"document.body.classList.add('modal-open');" +
|
||||
"var backdrop = document.createElement('div');" +
|
||||
"backdrop.className = 'modal-backdrop fade show';" +
|
||||
"document.body.appendChild(backdrop);");
|
||||
}
|
||||
}
|
||||
|
||||
protected string FormatDuration(TimeSpan duration)
|
||||
{
|
||||
if (duration.TotalHours >= 1)
|
||||
{
|
||||
return duration.ToString(@"h\:mm\:ss");
|
||||
}
|
||||
else if (duration.TotalMinutes >= 1)
|
||||
{
|
||||
return duration.ToString(@"m\:ss");
|
||||
}
|
||||
else
|
||||
{
|
||||
return $"{duration.TotalSeconds:F1}s";
|
||||
}
|
||||
}
|
||||
|
||||
protected async Task HideModal()
|
||||
{
|
||||
try
|
||||
{
|
||||
await JSRuntime.InvokeVoidAsync("eval",
|
||||
"if (typeof bootstrap !== 'undefined' && bootstrap.Modal) { " +
|
||||
"var modalElement = document.getElementById('executionDetailModal'); " +
|
||||
"var modal = bootstrap.Modal.getInstance(modalElement); " +
|
||||
"if (modal) modal.hide(); " +
|
||||
"} else { " +
|
||||
"document.getElementById('executionDetailModal').style.display = 'none'; " +
|
||||
"document.getElementById('executionDetailModal').classList.remove('show'); " +
|
||||
"document.body.classList.remove('modal-open'); " +
|
||||
"var backdrop = document.querySelector('.modal-backdrop'); " +
|
||||
"if (backdrop) backdrop.remove(); " +
|
||||
"}");
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Fallback: nascondi il modal manualmente
|
||||
await JSRuntime.InvokeVoidAsync("eval",
|
||||
"var modal = document.getElementById('executionDetailModal');" +
|
||||
"modal.style.display = 'none';" +
|
||||
"modal.classList.remove('show');" +
|
||||
"document.body.classList.remove('modal-open');" +
|
||||
"var backdrop = document.querySelector('.modal-backdrop');" +
|
||||
"if (backdrop) backdrop.remove();");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ShowErrorMessage(string message)
|
||||
{
|
||||
await JSRuntime.InvokeVoidAsync("alert", "Errore: " + message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
@page "/settings"
|
||||
@using Data_Coupler.Services
|
||||
@using Data_Coupler.Models
|
||||
@inject IJSRuntime JSRuntime
|
||||
@inject ILogger<SettingsPage> Logger
|
||||
|
||||
<PageTitle>Impostazioni - Data Coupler</PageTitle>
|
||||
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1>
|
||||
<i class="fas fa-cog text-primary"></i>
|
||||
Impostazioni Sistema
|
||||
</h1>
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="/">Home</a></li>
|
||||
<li class="breadcrumb-item active" aria-current="page">Impostazioni</li>
|
||||
</ol>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrEmpty(toastMessage))
|
||||
{
|
||||
<div class="alert alert-@(toastType == "success" ? "success" : toastType == "error" ? "danger" : "info") alert-dismissible fade show" role="alert">
|
||||
<i class="fas fa-@(toastType == "success" ? "check-circle" : toastType == "error" ? "exclamation-circle" : "info-circle")"></i>
|
||||
@toastMessage
|
||||
<button type="button" class="btn-close" @onclick="ClearToast" aria-label="Close"></button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Tab Navigation -->
|
||||
<ul class="nav nav-tabs mb-4" id="settingsTabs" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link @(activeTab == "backup" ? "active" : "")"
|
||||
id="backup-tab"
|
||||
@onclick='() => SetActiveTab("backup")'
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-controls="backup"
|
||||
aria-selected="@(activeTab == "backup")">
|
||||
<i class="fas fa-download me-2"></i>
|
||||
Backup e Ripristino
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link @(activeTab == "system" ? "active" : "")"
|
||||
id="system-tab"
|
||||
@onclick='() => SetActiveTab("system")'
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-controls="system"
|
||||
aria-selected="@(activeTab == "system")">
|
||||
<i class="fas fa-server me-2"></i>
|
||||
Sistema
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link @(activeTab == "security" ? "active" : "")"
|
||||
id="security-tab"
|
||||
@onclick='() => SetActiveTab("security")'
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-controls="security"
|
||||
aria-selected="@(activeTab == "security")">
|
||||
<i class="fas fa-shield-alt me-2"></i>
|
||||
Sicurezza
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link @(activeTab == "maintenance" ? "active" : "")"
|
||||
id="maintenance-tab"
|
||||
@onclick='() => SetActiveTab("maintenance")'
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-controls="maintenance"
|
||||
aria-selected="@(activeTab == "maintenance")">
|
||||
<i class="fas fa-tools me-2"></i>
|
||||
Manutenzione
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- Tab Content -->
|
||||
<div class="tab-content" id="settingsTabContent">
|
||||
<!-- Backup Tab -->
|
||||
<div class="tab-pane fade @(activeTab == "backup" ? "show active" : "")"
|
||||
id="backup"
|
||||
role="tabpanel"
|
||||
aria-labelledby="backup-tab">
|
||||
<BackupTab OnShowToast="ShowToastTuple" />
|
||||
</div>
|
||||
|
||||
<!-- System Tab -->
|
||||
<div class="tab-pane fade @(activeTab == "system" ? "show active" : "")"
|
||||
id="system"
|
||||
role="tabpanel"
|
||||
aria-labelledby="system-tab">
|
||||
<SystemTab OnShowToast="ShowToastTuple" />
|
||||
</div>
|
||||
|
||||
<!-- Security Tab -->
|
||||
<div class="tab-pane fade @(activeTab == "security" ? "show active" : "")"
|
||||
id="security"
|
||||
role="tabpanel"
|
||||
aria-labelledby="security-tab">
|
||||
<SecurityTab OnShowToast="ShowToastTuple" />
|
||||
</div>
|
||||
|
||||
<!-- Maintenance Tab -->
|
||||
<div class="tab-pane fade @(activeTab == "maintenance" ? "show active" : "")"
|
||||
id="maintenance"
|
||||
role="tabpanel"
|
||||
aria-labelledby="maintenance-tab">
|
||||
<MaintenanceTab OnShowToast="ShowToastTuple" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Include CSS per migliorare l'aspetto -->
|
||||
<style>
|
||||
.nav-tabs .nav-link {
|
||||
border: 1px solid transparent;
|
||||
border-radius: 0.375rem 0.375rem 0 0;
|
||||
color: #6c757d;
|
||||
background-color: #f8f9fa;
|
||||
margin-right: 2px;
|
||||
transition: all 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
.nav-tabs .nav-link:hover {
|
||||
border-color: #e9ecef #e9ecef #dee2e6;
|
||||
color: #495057;
|
||||
background-color: #e9ecef;
|
||||
}
|
||||
|
||||
.nav-tabs .nav-link.active {
|
||||
color: #495057;
|
||||
background-color: #fff;
|
||||
border-color: #dee2e6 #dee2e6 #fff;
|
||||
border-bottom: 1px solid #fff;
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
border: 1px solid #dee2e6;
|
||||
border-top: none;
|
||||
border-radius: 0 0 0.375rem 0.375rem;
|
||||
padding: 1.5rem;
|
||||
background-color: #fff;
|
||||
min-height: 500px;
|
||||
}
|
||||
|
||||
.settings-section {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.settings-section h4 {
|
||||
border-bottom: 2px solid #e9ecef;
|
||||
padding-bottom: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.alert {
|
||||
border-radius: 0.375rem;
|
||||
border: none;
|
||||
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
|
||||
}
|
||||
|
||||
.card {
|
||||
border: none;
|
||||
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
background-color: #f8f9fa;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
border-radius: 0.5rem 0.5rem 0 0 !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@code {
|
||||
private string activeTab = "backup";
|
||||
private string toastMessage = "";
|
||||
private string toastType = "info";
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
// Inizializzazione se necessaria
|
||||
await base.OnInitializedAsync();
|
||||
}
|
||||
|
||||
private void SetActiveTab(string tabName)
|
||||
{
|
||||
activeTab = tabName;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private void ShowToast(string message, string type = "info")
|
||||
{
|
||||
toastMessage = message;
|
||||
toastType = type;
|
||||
StateHasChanged();
|
||||
|
||||
// Auto-hide dopo 5 secondi per messaggi di successo
|
||||
if (type == "success")
|
||||
{
|
||||
_ = Task.Delay(5000).ContinueWith(_ => ClearToast());
|
||||
}
|
||||
}
|
||||
|
||||
private void ShowToastTuple((string message, string type) toast)
|
||||
{
|
||||
ShowToast(toast.message, toast.type);
|
||||
}
|
||||
|
||||
private void ClearToast()
|
||||
{
|
||||
toastMessage = "";
|
||||
toastType = "info";
|
||||
InvokeAsync(StateHasChanged);
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,7 @@
|
||||
</div>
|
||||
|
||||
<script src="_framework/blazor.server.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="js/site.js"></script>
|
||||
<script>
|
||||
window.downloadFileFromStream = async (fileName, contentStreamReference) => {
|
||||
|
||||
+112
-21
@@ -8,42 +8,78 @@ using DataConnection.Enums;
|
||||
using DataConnection.CredentialManagement;
|
||||
using CredentialManager;
|
||||
using Data_Coupler.Services;
|
||||
using Data_Coupler.BackgroundServices;
|
||||
using CredentialManager.Services;
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Data_Coupler.BackgrounServices;
|
||||
|
||||
// Registra il provider di encoding per ExcelDataReader (necessario per file .xls)
|
||||
System.Text.Encoding.RegisterProvider(System.Text.CodePagesEncodingProvider.Instance);
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Configurazione per Windows Service
|
||||
builder.Host.UseWindowsService(options =>
|
||||
{
|
||||
options.ServiceName = "DataCouplerService";
|
||||
});
|
||||
|
||||
// Add services to the container.
|
||||
builder.Services.AddRazorPages();
|
||||
builder.Services.AddServerSideBlazor();
|
||||
builder.Services.AddWindowsService();
|
||||
builder.Services.AddHostedService<BackgroundServices>();
|
||||
|
||||
// Configurazione logging per Windows Service
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
builder.Logging.AddEventLog();
|
||||
}
|
||||
|
||||
#region Database Directory Path management
|
||||
|
||||
string dbPath = string.Empty;
|
||||
|
||||
if (OperatingSystem.IsWindows())
|
||||
try
|
||||
{
|
||||
dbPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "Data_Coupler", "credentials.db");
|
||||
}
|
||||
else if (OperatingSystem.IsLinux())
|
||||
{
|
||||
dbPath = "/var/lib/Data_Coupler/credentials.db";
|
||||
}
|
||||
else if (OperatingSystem.IsMacOS())
|
||||
{
|
||||
dbPath = "/Library/Application Support/Data_Coupler/credentials.db";
|
||||
}
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
// Per servizi Windows, usa una cartella con permessi appropriati
|
||||
var appDataPath = Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData);
|
||||
if (string.IsNullOrEmpty(appDataPath))
|
||||
{
|
||||
// Fallback per servizi Windows
|
||||
appDataPath = @"C:\ProgramData";
|
||||
}
|
||||
dbPath = Path.Combine(appDataPath, "Data_Coupler", "credentials.db");
|
||||
}
|
||||
else if (OperatingSystem.IsLinux())
|
||||
{
|
||||
dbPath = "/var/lib/Data_Coupler/credentials.db";
|
||||
}
|
||||
else if (OperatingSystem.IsMacOS())
|
||||
{
|
||||
dbPath = "/Library/Application Support/Data_Coupler/credentials.db";
|
||||
}
|
||||
|
||||
var dbDirectory = Path.GetDirectoryName(dbPath);
|
||||
if (!Directory.Exists(dbDirectory))
|
||||
var dbDirectory = Path.GetDirectoryName(dbPath);
|
||||
if (!Directory.Exists(dbDirectory))
|
||||
{
|
||||
Directory.CreateDirectory(dbDirectory!);
|
||||
|
||||
// Per Windows, assicurati che la cartella abbia i permessi corretti
|
||||
if (OperatingSystem.IsWindows() && !string.IsNullOrEmpty(dbDirectory))
|
||||
{
|
||||
var directoryInfo = new DirectoryInfo(dbDirectory);
|
||||
// Imposta permessi per consentire l'accesso al servizio
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine($"Database path: {dbPath}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Directory.CreateDirectory(dbDirectory!);
|
||||
Console.WriteLine($"Errore nella configurazione del percorso database: {ex.Message}");
|
||||
throw;
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -63,24 +99,79 @@ builder.Services.AddHttpClient();
|
||||
|
||||
// Register Data Connection Factory
|
||||
builder.Services.AddScoped<IDataConnectionFactory, DataConnectionFactory>();
|
||||
builder.WebHost.UseUrls("http://*:7550");
|
||||
|
||||
// Register Backup Service
|
||||
builder.Services.AddScoped<Data_Coupler.Services.IBackupService, Data_Coupler.Services.BackupService>();
|
||||
|
||||
// Register Schedule Services
|
||||
builder.Services.AddScoped<IProfileScheduleService, ProfileScheduleService>();
|
||||
|
||||
// Register Data Transfer Service
|
||||
builder.Services.AddScoped<Data_Coupler.Services.IDataTransferService, Data_Coupler.Services.DataTransferService>();
|
||||
|
||||
// Register Scheduled Profile Execution Service
|
||||
builder.Services.AddScoped<Data_Coupler.Services.IScheduledProfileExecutionService, Data_Coupler.Services.ScheduledProfileExecutionService>();
|
||||
|
||||
// Register Background Services (solo uno per evitare duplicazioni)
|
||||
builder.Services.AddHostedService<Data_Coupler.BackgroundServices.ScheduledJobService>();
|
||||
|
||||
// Configurazione URL e timeout per servizio Windows
|
||||
var urls = builder.Configuration.GetValue<string>("Urls") ?? "http://*:7550";
|
||||
builder.WebHost.UseUrls(urls);
|
||||
|
||||
// Configurazione timeout per servizio Windows
|
||||
builder.WebHost.ConfigureKestrel(serverOptions =>
|
||||
{
|
||||
serverOptions.Limits.KeepAliveTimeout = TimeSpan.FromMinutes(10);
|
||||
serverOptions.Limits.RequestHeadersTimeout = TimeSpan.FromMinutes(5);
|
||||
});
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Initialize database
|
||||
// Initialize database con timeout e retry
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
var logger = scope.ServiceProvider.GetRequiredService<ILogger<Program>>();
|
||||
try
|
||||
{
|
||||
logger.LogInformation("Inizializzazione database in corso...");
|
||||
logger.LogInformation("Inizializzazione database in corso... Path: {DbPath}", dbPath);
|
||||
|
||||
var dbInitializer = scope.ServiceProvider.GetRequiredService<CredentialManager.Services.IDatabaseInitializer>();
|
||||
dbInitializer.InitializeAsync().GetAwaiter().GetResult();
|
||||
|
||||
// Inizializzazione con timeout di 60 secondi
|
||||
var initTask = dbInitializer.InitializeAsync();
|
||||
var timeoutTask = Task.Delay(TimeSpan.FromSeconds(60));
|
||||
|
||||
var completedTask = await Task.WhenAny(initTask, timeoutTask);
|
||||
|
||||
if (completedTask == timeoutTask)
|
||||
{
|
||||
logger.LogError("Timeout durante l'inizializzazione del database (60 secondi)");
|
||||
throw new TimeoutException("Timeout durante l'inizializzazione del database");
|
||||
}
|
||||
|
||||
await initTask; // Attendi il completamento per eventuali eccezioni
|
||||
logger.LogInformation("Database inizializzato con successo.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Errore durante l'inizializzazione del database: {Message}", ex.Message);
|
||||
logger.LogError(ex, "Errore durante l'inizializzazione del database: {Message}. Path: {DbPath}", ex.Message, dbPath);
|
||||
|
||||
// Per servizi Windows, log su Event Log
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
try
|
||||
{
|
||||
using var eventLog = new System.Diagnostics.EventLog("Application");
|
||||
eventLog.Source = "DataCouplerService";
|
||||
eventLog.WriteEntry($"Errore inizializzazione database: {ex.Message}", System.Diagnostics.EventLogEntryType.Error);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignora errori di scrittura EventLog
|
||||
}
|
||||
}
|
||||
|
||||
throw; // Rilancia l'eccezione per non far partire l'app con un database non funzionante
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
-- Migrazione per aggiungere supporto completo allo scheduling con storico esecuzioni
|
||||
|
||||
-- 1. Aggiungi colonne per override database nella tabella ProfileSchedules
|
||||
ALTER TABLE ProfileSchedules ADD COLUMN SourceDatabaseOverride TEXT;
|
||||
ALTER TABLE ProfileSchedules ADD COLUMN DestinationDatabaseOverride TEXT;
|
||||
|
||||
-- 2. Crea tabella per lo storico delle esecuzioni
|
||||
CREATE TABLE IF NOT EXISTS ScheduleExecutionHistories (
|
||||
Id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ScheduleId INTEGER NOT NULL,
|
||||
ProfileId INTEGER NOT NULL,
|
||||
ProfileName TEXT NOT NULL,
|
||||
StartTime DATETIME NOT NULL,
|
||||
EndTime DATETIME,
|
||||
Status TEXT NOT NULL,
|
||||
Message TEXT,
|
||||
RecordsProcessed INTEGER DEFAULT 0,
|
||||
RecordsWithErrors INTEGER,
|
||||
ErrorDetails TEXT,
|
||||
TriggerType TEXT NOT NULL,
|
||||
TriggeredBy TEXT,
|
||||
SourceType TEXT,
|
||||
DestinationType TEXT,
|
||||
SourceInfo TEXT,
|
||||
DestinationInfo TEXT,
|
||||
AdditionalInfo TEXT,
|
||||
CreatedAt DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (ScheduleId) REFERENCES ProfileSchedules (Id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- 3. Crea indici per ottimizzare le query
|
||||
CREATE INDEX IF NOT EXISTS IDX_ScheduleExecutionHistories_ScheduleId ON ScheduleExecutionHistories (ScheduleId);
|
||||
CREATE INDEX IF NOT EXISTS IDX_ScheduleExecutionHistories_ProfileId ON ScheduleExecutionHistories (ProfileId);
|
||||
CREATE INDEX IF NOT EXISTS IDX_ScheduleExecutionHistories_Status ON ScheduleExecutionHistories (Status);
|
||||
CREATE INDEX IF NOT EXISTS IDX_ScheduleExecutionHistories_StartTime ON ScheduleExecutionHistories (StartTime);
|
||||
CREATE INDEX IF NOT EXISTS IDX_ScheduleExecutionHistories_TriggerType ON ScheduleExecutionHistories (TriggerType);
|
||||
|
||||
-- 4. Aggiorna eventuali schedulazioni esistenti per impostare NextExecutionTime se null
|
||||
-- Questo è utile se ci sono già delle schedulazioni nel database
|
||||
UPDATE ProfileSchedules
|
||||
SET NextExecutionTime = datetime('now', '+1 hour')
|
||||
WHERE NextExecutionTime IS NULL AND IsActive = 1 AND IsEnabled = 1;
|
||||
|
||||
PRAGMA foreign_keys = ON;
|
||||
@@ -0,0 +1,733 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using CredentialManager.Data;
|
||||
using CredentialManager.Models;
|
||||
using CredentialManager.Services;
|
||||
using Data_Coupler.Models;
|
||||
|
||||
namespace Data_Coupler.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Interfaccia per il servizio di backup e ripristino
|
||||
/// </summary>
|
||||
public interface IBackupService
|
||||
{
|
||||
/// <summary>
|
||||
/// Esporta tutti i dati del sistema in un file di backup
|
||||
/// </summary>
|
||||
Task<BackupOperationResult> ExportBackupAsync(BackupOptions options);
|
||||
|
||||
/// <summary>
|
||||
/// Importa i dati da un file di backup
|
||||
/// </summary>
|
||||
Task<BackupOperationResult> ImportBackupAsync(string backupFilePath, RestoreOptions options);
|
||||
|
||||
/// <summary>
|
||||
/// Importa i dati da contenuto JSON
|
||||
/// </summary>
|
||||
Task<BackupOperationResult> ImportBackupFromJsonAsync(string jsonContent, RestoreOptions options);
|
||||
|
||||
/// <summary>
|
||||
/// Valida un file di backup
|
||||
/// </summary>
|
||||
Task<BackupOperationResult> ValidateBackupAsync(string backupFilePath);
|
||||
|
||||
/// <summary>
|
||||
/// Ottiene le informazioni su un backup senza importarlo
|
||||
/// </summary>
|
||||
Task<SystemBackupData?> GetBackupInfoAsync(string backupFilePath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Servizio per la gestione dei backup e ripristini del sistema
|
||||
/// </summary>
|
||||
public class BackupService : IBackupService
|
||||
{
|
||||
private readonly CredentialDbContext _context;
|
||||
private readonly IDataCouplerProfileService _profileService;
|
||||
private readonly ICredentialService _credentialService;
|
||||
private readonly IKeyAssociationService _keyAssociationService;
|
||||
private readonly ILogger<BackupService> _logger;
|
||||
|
||||
public BackupService(
|
||||
CredentialDbContext context,
|
||||
IDataCouplerProfileService profileService,
|
||||
ICredentialService credentialService,
|
||||
IKeyAssociationService keyAssociationService,
|
||||
ILogger<BackupService> logger)
|
||||
{
|
||||
_context = context;
|
||||
_profileService = profileService;
|
||||
_credentialService = credentialService;
|
||||
_keyAssociationService = keyAssociationService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Esporta tutti i dati del sistema
|
||||
/// </summary>
|
||||
public async Task<BackupOperationResult> ExportBackupAsync(BackupOptions options)
|
||||
{
|
||||
var result = new BackupOperationResult();
|
||||
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Avvio backup con opzioni: {@Options}", options);
|
||||
|
||||
var backupData = new SystemBackupData
|
||||
{
|
||||
Metadata = new BackupMetadata
|
||||
{
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
CreatedBy = options.CreatedBy,
|
||||
Description = options.Description,
|
||||
ApplicationVersion = "1.0.0"
|
||||
}
|
||||
};
|
||||
|
||||
// Export Profiles
|
||||
if (options.IncludeProfiles)
|
||||
{
|
||||
_logger.LogDebug("Esportazione profili in corso...");
|
||||
backupData.Profiles = await ExportProfilesAsync(options.IncludeOnlyActiveRecords);
|
||||
result.ProcessedCounts.Profiles = backupData.Profiles.Count;
|
||||
_logger.LogDebug("Esportati {Count} profili", backupData.Profiles.Count);
|
||||
}
|
||||
|
||||
// Export Credentials (senza dati sensibili)
|
||||
if (options.IncludeCredentials)
|
||||
{
|
||||
_logger.LogDebug("Esportazione credenziali in corso...");
|
||||
backupData.Credentials = await ExportCredentialsAsync(options.IncludeOnlyActiveRecords);
|
||||
result.ProcessedCounts.Credentials = backupData.Credentials.Count;
|
||||
result.Warnings.Add("Le credenziali esportate non includono password, API keys o token per motivi di sicurezza");
|
||||
_logger.LogDebug("Esportate {Count} credenziali", backupData.Credentials.Count);
|
||||
}
|
||||
|
||||
// Export Key Associations
|
||||
if (options.IncludeKeyAssociations)
|
||||
{
|
||||
_logger.LogDebug("Esportazione associazioni chiavi in corso...");
|
||||
backupData.KeyAssociations = await ExportKeyAssociationsAsync(options.IncludeOnlyActiveRecords);
|
||||
result.ProcessedCounts.KeyAssociations = backupData.KeyAssociations.Count;
|
||||
_logger.LogDebug("Esportate {Count} associazioni", backupData.KeyAssociations.Count);
|
||||
}
|
||||
|
||||
// Export Profile Schedules
|
||||
if (options.IncludeProfileSchedules)
|
||||
{
|
||||
_logger.LogDebug("Esportazione schedule profili in corso...");
|
||||
backupData.ProfileSchedules = await ExportProfileSchedulesAsync(options.IncludeOnlyActiveRecords);
|
||||
result.ProcessedCounts.ProfileSchedules = backupData.ProfileSchedules.Count;
|
||||
_logger.LogDebug("Esportate {Count} schedule", backupData.ProfileSchedules.Count);
|
||||
}
|
||||
|
||||
// Aggiorna metadata con conteggi
|
||||
backupData.Metadata.RecordCounts = result.ProcessedCounts;
|
||||
|
||||
// Serializza e salva
|
||||
var jsonOptions = new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(backupData, jsonOptions);
|
||||
var fileName = $"data_coupler_backup_{DateTime.Now:yyyyMMdd_HHmmss}.json";
|
||||
var documentsPath = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments);
|
||||
var backupFolder = Path.Combine(documentsPath, "DataCoupler", "Backups");
|
||||
Directory.CreateDirectory(backupFolder);
|
||||
|
||||
var filePath = Path.Combine(backupFolder, fileName);
|
||||
await File.WriteAllTextAsync(filePath, json);
|
||||
|
||||
result.FilePath = filePath;
|
||||
result.Success = true;
|
||||
result.Message = $"Backup completato con successo. File salvato: {filePath}";
|
||||
|
||||
_logger.LogInformation("Backup completato: {FilePath}, Record totali: {Total}",
|
||||
filePath,
|
||||
result.ProcessedCounts.Profiles + result.ProcessedCounts.Credentials +
|
||||
result.ProcessedCounts.KeyAssociations + result.ProcessedCounts.ProfileSchedules);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
result.Success = false;
|
||||
result.Message = $"Errore durante il backup: {ex.Message}";
|
||||
result.Errors.Add(ex.ToString());
|
||||
_logger.LogError(ex, "Errore durante l'esportazione del backup");
|
||||
}
|
||||
finally
|
||||
{
|
||||
stopwatch.Stop();
|
||||
result.Duration = stopwatch.Elapsed;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Importa dati da file backup
|
||||
/// </summary>
|
||||
public async Task<BackupOperationResult> ImportBackupAsync(string backupFilePath, RestoreOptions options)
|
||||
{
|
||||
if (!File.Exists(backupFilePath))
|
||||
{
|
||||
return new BackupOperationResult
|
||||
{
|
||||
Success = false,
|
||||
Message = "File di backup non trovato"
|
||||
};
|
||||
}
|
||||
|
||||
var json = await File.ReadAllTextAsync(backupFilePath);
|
||||
return await ImportBackupFromJsonAsync(json, options);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Importa dati da contenuto JSON
|
||||
/// </summary>
|
||||
public async Task<BackupOperationResult> ImportBackupFromJsonAsync(string jsonContent, RestoreOptions options)
|
||||
{
|
||||
var result = new BackupOperationResult();
|
||||
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Avvio import backup con opzioni: {@Options}", options);
|
||||
|
||||
// Parse JSON
|
||||
var jsonOptions = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
var backupData = JsonSerializer.Deserialize<SystemBackupData>(jsonContent, jsonOptions);
|
||||
if (backupData == null)
|
||||
{
|
||||
throw new InvalidOperationException("Impossibile deserializzare i dati del backup");
|
||||
}
|
||||
|
||||
_logger.LogInformation("Backup da importare: {Metadata}", backupData.Metadata);
|
||||
|
||||
// Crea backup automatico prima del restore se richiesto
|
||||
if (options.CreateBackupBeforeRestore)
|
||||
{
|
||||
_logger.LogInformation("Creazione backup di sicurezza pre-restore...");
|
||||
var preRestoreBackup = await ExportBackupAsync(new BackupOptions
|
||||
{
|
||||
Description = "Backup automatico pre-restore",
|
||||
CreatedBy = options.ImportedBy
|
||||
});
|
||||
|
||||
if (preRestoreBackup.Success)
|
||||
{
|
||||
result.Warnings.Add($"Backup di sicurezza creato: {preRestoreBackup.FilePath}");
|
||||
}
|
||||
}
|
||||
|
||||
using var transaction = await _context.Database.BeginTransactionAsync();
|
||||
try
|
||||
{
|
||||
// Import Profiles
|
||||
if (options.RestoreProfiles && backupData.Profiles.Any())
|
||||
{
|
||||
_logger.LogDebug("Importazione profili in corso...");
|
||||
var importedProfiles = await ImportProfilesAsync(backupData.Profiles, options);
|
||||
result.ProcessedCounts.Profiles = importedProfiles;
|
||||
_logger.LogDebug("Importati {Count} profili", importedProfiles);
|
||||
}
|
||||
|
||||
// Import Credentials (solo metadati, nessun dato sensibile)
|
||||
if (options.RestoreCredentials && backupData.Credentials.Any())
|
||||
{
|
||||
_logger.LogDebug("Importazione credenziali in corso...");
|
||||
var importedCredentials = await ImportCredentialsAsync(backupData.Credentials, options);
|
||||
result.ProcessedCounts.Credentials = importedCredentials;
|
||||
result.Warnings.Add("Le credenziali importate richiedono configurazione manuale di password/API keys");
|
||||
_logger.LogDebug("Importate {Count} credenziali", importedCredentials);
|
||||
}
|
||||
|
||||
// Import Key Associations
|
||||
if (options.RestoreKeyAssociations && backupData.KeyAssociations.Any())
|
||||
{
|
||||
_logger.LogDebug("Importazione associazioni chiavi in corso...");
|
||||
var importedAssociations = await ImportKeyAssociationsAsync(backupData.KeyAssociations, options);
|
||||
result.ProcessedCounts.KeyAssociations = importedAssociations;
|
||||
_logger.LogDebug("Importate {Count} associazioni", importedAssociations);
|
||||
}
|
||||
|
||||
// Import Profile Schedules
|
||||
if (options.RestoreProfileSchedules && backupData.ProfileSchedules.Any())
|
||||
{
|
||||
_logger.LogDebug("Importazione schedule profili in corso...");
|
||||
var importedSchedules = await ImportProfileSchedulesAsync(backupData.ProfileSchedules, options);
|
||||
result.ProcessedCounts.ProfileSchedules = importedSchedules;
|
||||
_logger.LogDebug("Importate {Count} schedule", importedSchedules);
|
||||
}
|
||||
|
||||
await transaction.CommitAsync();
|
||||
|
||||
result.Success = true;
|
||||
result.Message = "Import completato con successo";
|
||||
|
||||
_logger.LogInformation("Import completato con successo. Record totali: {Total}",
|
||||
result.ProcessedCounts.Profiles + result.ProcessedCounts.Credentials +
|
||||
result.ProcessedCounts.KeyAssociations + result.ProcessedCounts.ProfileSchedules);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
result.Success = false;
|
||||
result.Message = $"Errore durante l'import: {ex.Message}";
|
||||
result.Errors.Add(ex.ToString());
|
||||
_logger.LogError(ex, "Errore durante l'importazione del backup");
|
||||
}
|
||||
finally
|
||||
{
|
||||
stopwatch.Stop();
|
||||
result.Duration = stopwatch.Elapsed;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Valida un file di backup
|
||||
/// </summary>
|
||||
public async Task<BackupOperationResult> ValidateBackupAsync(string backupFilePath)
|
||||
{
|
||||
var result = new BackupOperationResult();
|
||||
|
||||
try
|
||||
{
|
||||
if (!File.Exists(backupFilePath))
|
||||
{
|
||||
result.Errors.Add("File non trovato");
|
||||
return result;
|
||||
}
|
||||
|
||||
var json = await File.ReadAllTextAsync(backupFilePath);
|
||||
var backupData = JsonSerializer.Deserialize<SystemBackupData>(json);
|
||||
|
||||
if (backupData == null)
|
||||
{
|
||||
result.Errors.Add("Formato backup non valido");
|
||||
return result;
|
||||
}
|
||||
|
||||
// Validazioni
|
||||
if (backupData.Metadata == null)
|
||||
{
|
||||
result.Errors.Add("Metadata mancanti");
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(backupData.Metadata?.Version))
|
||||
{
|
||||
result.Warnings.Add("Versione backup non specificata");
|
||||
}
|
||||
|
||||
result.ProcessedCounts.Profiles = backupData.Profiles?.Count ?? 0;
|
||||
result.ProcessedCounts.Credentials = backupData.Credentials?.Count ?? 0;
|
||||
result.ProcessedCounts.KeyAssociations = backupData.KeyAssociations?.Count ?? 0;
|
||||
result.ProcessedCounts.ProfileSchedules = backupData.ProfileSchedules?.Count ?? 0;
|
||||
|
||||
result.Success = result.Errors.Count == 0;
|
||||
result.Message = result.Success ? "Backup valido" : "Backup contiene errori";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
result.Success = false;
|
||||
result.Message = $"Errore validazione: {ex.Message}";
|
||||
result.Errors.Add(ex.ToString());
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ottiene informazioni backup senza importare
|
||||
/// </summary>
|
||||
public async Task<SystemBackupData?> GetBackupInfoAsync(string backupFilePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(backupFilePath))
|
||||
return null;
|
||||
|
||||
var json = await File.ReadAllTextAsync(backupFilePath);
|
||||
return JsonSerializer.Deserialize<SystemBackupData>(json);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Errore lettura info backup da {FilePath}", backupFilePath);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
#region Private Export Methods
|
||||
|
||||
private async Task<List<DataCouplerProfileBackup>> ExportProfilesAsync(bool onlyActive)
|
||||
{
|
||||
var query = _context.DataCouplerProfiles
|
||||
.Include(p => p.SourceCredential)
|
||||
.Include(p => p.DestinationCredential)
|
||||
.AsQueryable();
|
||||
|
||||
if (onlyActive)
|
||||
query = query.Where(p => p.IsActive);
|
||||
|
||||
var profiles = await query.ToListAsync();
|
||||
|
||||
return profiles.Select(p => new DataCouplerProfileBackup
|
||||
{
|
||||
Id = p.Id,
|
||||
Name = p.Name,
|
||||
Description = p.Description,
|
||||
SourceType = p.SourceType,
|
||||
SourceCredentialName = p.SourceCredential?.Name,
|
||||
SourceDatabaseName = p.SourceDatabaseName,
|
||||
SourceSchema = p.SourceSchema,
|
||||
SourceTable = p.SourceTable,
|
||||
SourceCustomQuery = p.SourceCustomQuery,
|
||||
SourceFilePath = p.SourceFilePath,
|
||||
DestinationType = p.DestinationType,
|
||||
DestinationCredentialName = p.DestinationCredential?.Name,
|
||||
DestinationSchema = p.DestinationSchema,
|
||||
DestinationTable = p.DestinationTable,
|
||||
DestinationEndpoint = p.DestinationEndpoint,
|
||||
FieldMappings = !string.IsNullOrEmpty(p.FieldMappingJson) ?
|
||||
System.Text.Json.JsonSerializer.Deserialize<List<FieldMappingDto>>(p.FieldMappingJson) ?? new List<FieldMappingDto>() :
|
||||
new List<FieldMappingDto>(),
|
||||
SourceKeyField = p.SourceKeyField,
|
||||
UseRecordAssociations = p.UseRecordAssociations,
|
||||
CreatedBy = p.CreatedBy,
|
||||
CreatedAt = p.CreatedAt,
|
||||
LastUsedAt = p.LastUsedAt,
|
||||
IsActive = p.IsActive
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
private async Task<List<CredentialBackup>> ExportCredentialsAsync(bool onlyActive)
|
||||
{
|
||||
var query = _context.Credentials.AsQueryable();
|
||||
|
||||
if (onlyActive)
|
||||
query = query.Where(c => c.IsActive);
|
||||
|
||||
var credentials = await query.ToListAsync();
|
||||
|
||||
return credentials.Select(c => new CredentialBackup
|
||||
{
|
||||
Id = c.Id,
|
||||
Name = c.Name,
|
||||
Type = c.Type,
|
||||
DatabaseType = c.DatabaseType,
|
||||
Host = c.Host,
|
||||
Port = c.Port,
|
||||
DatabaseName = c.DatabaseName,
|
||||
Username = c.Username,
|
||||
// Password, API Keys e Token NON inclusi per sicurezza
|
||||
CommandTimeout = c.CommandTimeout,
|
||||
TimeoutSeconds = c.TimeoutSeconds,
|
||||
IgnoreSslErrors = c.IgnoreSslErrors,
|
||||
RestServiceType = c.RestServiceType,
|
||||
Headers = c.Headers,
|
||||
AdditionalParameters = c.AdditionalParameters,
|
||||
CreatedAt = c.CreatedAt,
|
||||
UpdatedAt = c.UpdatedAt,
|
||||
CreatedBy = c.CreatedBy,
|
||||
IsActive = c.IsActive
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
private async Task<List<KeyAssociationBackup>> ExportKeyAssociationsAsync(bool onlyActive)
|
||||
{
|
||||
var query = _context.KeyAssociations.AsQueryable();
|
||||
|
||||
if (onlyActive)
|
||||
query = query.Where(ka => ka.IsActive);
|
||||
|
||||
var associations = await query.ToListAsync();
|
||||
|
||||
return associations.Select(ka => new KeyAssociationBackup
|
||||
{
|
||||
Id = ka.Id,
|
||||
KeyValue = ka.KeyValue,
|
||||
SourceKeyField = ka.SourceKeyField,
|
||||
DestinationKeyField = ka.DestinationKeyField,
|
||||
DestinationEntity = ka.DestinationEntity,
|
||||
DestinationId = ka.DestinationId,
|
||||
RestCredentialName = ka.RestCredentialName,
|
||||
CreatedAt = ka.CreatedAt,
|
||||
UpdatedAt = ka.UpdatedAt,
|
||||
LastVerifiedAt = ka.LastVerifiedAt,
|
||||
IsActive = ka.IsActive,
|
||||
DataHash = ka.Data_Hash,
|
||||
SourcesInfo = ka.SourcesInfo,
|
||||
AdditionalInfo = ka.AdditionalInfo
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
private Task<List<ProfileScheduleBackup>> ExportProfileSchedulesAsync(bool onlyActive)
|
||||
{
|
||||
// Nota: Assumendo che esista una tabella ProfileSchedules
|
||||
// Se non esiste, questo metodo restituirà una lista vuota
|
||||
var schedules = new List<ProfileScheduleBackup>();
|
||||
|
||||
try
|
||||
{
|
||||
// TODO: Implementare quando la tabella ProfileSchedules sarà disponibile
|
||||
// var query = _context.ProfileSchedules.AsQueryable();
|
||||
// if (onlyActive)
|
||||
// query = query.Where(ps => ps.IsActive);
|
||||
// var profileSchedules = await query.ToListAsync();
|
||||
// schedules = profileSchedules.Select(ps => new ProfileScheduleBackup { ... }).ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Tabella ProfileSchedules non disponibile, saltando export");
|
||||
}
|
||||
|
||||
return Task.FromResult(schedules);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Private Import Methods
|
||||
|
||||
private async Task<int> ImportProfilesAsync(List<DataCouplerProfileBackup> profiles, RestoreOptions options)
|
||||
{
|
||||
int imported = 0;
|
||||
|
||||
foreach (var profileBackup in profiles)
|
||||
{
|
||||
try
|
||||
{
|
||||
var existing = await _context.DataCouplerProfiles
|
||||
.FirstOrDefaultAsync(p => p.Name == profileBackup.Name);
|
||||
|
||||
if (existing != null && !options.OverwriteExisting)
|
||||
{
|
||||
_logger.LogDebug("Profilo {Name} già esistente, saltando", profileBackup.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
var profile = new DataCouplerProfile
|
||||
{
|
||||
Name = profileBackup.Name,
|
||||
Description = profileBackup.Description,
|
||||
SourceType = profileBackup.SourceType,
|
||||
SourceDatabaseName = profileBackup.SourceDatabaseName,
|
||||
SourceSchema = profileBackup.SourceSchema,
|
||||
SourceTable = profileBackup.SourceTable,
|
||||
SourceCustomQuery = profileBackup.SourceCustomQuery,
|
||||
SourceFilePath = profileBackup.SourceFilePath,
|
||||
DestinationType = profileBackup.DestinationType,
|
||||
DestinationSchema = profileBackup.DestinationSchema,
|
||||
DestinationTable = profileBackup.DestinationTable,
|
||||
DestinationEndpoint = profileBackup.DestinationEndpoint,
|
||||
FieldMappingJson = profileBackup.FieldMappings != null ?
|
||||
System.Text.Json.JsonSerializer.Serialize(profileBackup.FieldMappings) :
|
||||
string.Empty,
|
||||
SourceKeyField = profileBackup.SourceKeyField,
|
||||
UseRecordAssociations = profileBackup.UseRecordAssociations,
|
||||
CreatedBy = options.ImportedBy ?? profileBackup.CreatedBy,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
IsActive = profileBackup.IsActive
|
||||
};
|
||||
|
||||
// Risolvi credential IDs per nome se esistenti
|
||||
if (!string.IsNullOrEmpty(profileBackup.SourceCredentialName))
|
||||
{
|
||||
var sourceCred = await _context.Credentials
|
||||
.FirstOrDefaultAsync(c => c.Name == profileBackup.SourceCredentialName);
|
||||
profile.SourceCredentialId = sourceCred?.Id;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(profileBackup.DestinationCredentialName))
|
||||
{
|
||||
var destCred = await _context.Credentials
|
||||
.FirstOrDefaultAsync(c => c.Name == profileBackup.DestinationCredentialName);
|
||||
profile.DestinationCredentialId = destCred?.Id;
|
||||
}
|
||||
|
||||
if (existing != null)
|
||||
{
|
||||
// Update existing
|
||||
_context.Entry(existing).CurrentValues.SetValues(profile);
|
||||
existing.Id = profileBackup.Id; // Mantieni ID originale se sovrascriviamo
|
||||
}
|
||||
else
|
||||
{
|
||||
// Add new
|
||||
_context.DataCouplerProfiles.Add(profile);
|
||||
}
|
||||
|
||||
imported++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Errore importazione profilo {Name}", profileBackup.Name);
|
||||
}
|
||||
}
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
return imported;
|
||||
}
|
||||
|
||||
private async Task<int> ImportCredentialsAsync(List<CredentialBackup> credentials, RestoreOptions options)
|
||||
{
|
||||
int imported = 0;
|
||||
|
||||
foreach (var credBackup in credentials)
|
||||
{
|
||||
try
|
||||
{
|
||||
var existing = await _context.Credentials
|
||||
.FirstOrDefaultAsync(c => c.Name == credBackup.Name);
|
||||
|
||||
if (existing != null && !options.OverwriteExisting)
|
||||
{
|
||||
_logger.LogDebug("Credenziale {Name} già esistente, saltando", credBackup.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
var credential = new CredentialEntity
|
||||
{
|
||||
Name = credBackup.Name,
|
||||
Type = credBackup.Type,
|
||||
DatabaseType = credBackup.DatabaseType,
|
||||
Host = credBackup.Host,
|
||||
Port = credBackup.Port,
|
||||
DatabaseName = credBackup.DatabaseName,
|
||||
Username = credBackup.Username,
|
||||
// Password, API Keys e Token dovranno essere riconfigurati manualmente
|
||||
CommandTimeout = credBackup.CommandTimeout,
|
||||
TimeoutSeconds = credBackup.TimeoutSeconds,
|
||||
IgnoreSslErrors = credBackup.IgnoreSslErrors,
|
||||
RestServiceType = credBackup.RestServiceType,
|
||||
Headers = credBackup.Headers,
|
||||
AdditionalParameters = credBackup.AdditionalParameters,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
CreatedBy = options.ImportedBy ?? credBackup.CreatedBy,
|
||||
IsActive = credBackup.IsActive
|
||||
};
|
||||
|
||||
if (existing != null)
|
||||
{
|
||||
// Update existing (preserva password esistenti)
|
||||
var oldPassword = existing.EncryptedPassword;
|
||||
var oldApiKey = existing.EncryptedApiKey;
|
||||
var oldAuthToken = existing.EncryptedAuthToken;
|
||||
|
||||
_context.Entry(existing).CurrentValues.SetValues(credential);
|
||||
existing.Id = credBackup.Id;
|
||||
existing.EncryptedPassword = oldPassword;
|
||||
existing.EncryptedApiKey = oldApiKey;
|
||||
existing.EncryptedAuthToken = oldAuthToken;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Add new
|
||||
_context.Credentials.Add(credential);
|
||||
}
|
||||
|
||||
imported++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Errore importazione credenziale {Name}", credBackup.Name);
|
||||
}
|
||||
}
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
return imported;
|
||||
}
|
||||
|
||||
private async Task<int> ImportKeyAssociationsAsync(List<KeyAssociationBackup> associations, RestoreOptions options)
|
||||
{
|
||||
int imported = 0;
|
||||
|
||||
foreach (var assocBackup in associations)
|
||||
{
|
||||
try
|
||||
{
|
||||
var existing = await _context.KeyAssociations
|
||||
.FirstOrDefaultAsync(ka => ka.KeyValue == assocBackup.KeyValue &&
|
||||
ka.DestinationEntity == assocBackup.DestinationEntity &&
|
||||
ka.RestCredentialName == assocBackup.RestCredentialName);
|
||||
|
||||
if (existing != null && !options.OverwriteExisting)
|
||||
{
|
||||
_logger.LogDebug("Associazione {Key}-{Entity} già esistente, saltando",
|
||||
assocBackup.KeyValue, assocBackup.DestinationEntity);
|
||||
continue;
|
||||
}
|
||||
|
||||
var association = new KeyAssociation
|
||||
{
|
||||
KeyValue = assocBackup.KeyValue,
|
||||
SourceKeyField = assocBackup.SourceKeyField,
|
||||
DestinationKeyField = assocBackup.DestinationKeyField,
|
||||
DestinationEntity = assocBackup.DestinationEntity,
|
||||
DestinationId = assocBackup.DestinationId,
|
||||
RestCredentialName = assocBackup.RestCredentialName,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
IsActive = assocBackup.IsActive,
|
||||
Data_Hash = assocBackup.DataHash,
|
||||
SourcesInfo = assocBackup.SourcesInfo,
|
||||
AdditionalInfo = assocBackup.AdditionalInfo
|
||||
};
|
||||
|
||||
if (existing != null)
|
||||
{
|
||||
// Update existing
|
||||
_context.Entry(existing).CurrentValues.SetValues(association);
|
||||
existing.Id = assocBackup.Id;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Add new
|
||||
_context.KeyAssociations.Add(association);
|
||||
}
|
||||
|
||||
imported++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Errore importazione associazione {Key}-{Entity}",
|
||||
assocBackup.KeyValue, assocBackup.DestinationEntity);
|
||||
}
|
||||
}
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
return imported;
|
||||
}
|
||||
|
||||
private Task<int> ImportProfileSchedulesAsync(List<ProfileScheduleBackup> schedules, RestoreOptions options)
|
||||
{
|
||||
int imported = 0;
|
||||
|
||||
try
|
||||
{
|
||||
// TODO: Implementare quando la tabella ProfileSchedules sarà disponibile
|
||||
_logger.LogInformation("Import ProfileSchedules saltato - tabella non ancora implementata");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Errore importazione schedule profili");
|
||||
}
|
||||
|
||||
return Task.FromResult(imported);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
using CredentialManager.Models;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Data_Coupler.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Servizio per l'esecuzione automatica dei trasferimenti dati basati sui profili
|
||||
/// </summary>
|
||||
public interface IDataTransferService
|
||||
{
|
||||
Task<DataTransferResult> ExecuteProfileAsync(DataCouplerProfile profile, string? sourceDatabaseOverride = null, string? destinationDatabaseOverride = null);
|
||||
}
|
||||
|
||||
public class DataTransferResult
|
||||
{
|
||||
public bool IsSuccess { get; set; }
|
||||
public int RecordsProcessed { get; set; }
|
||||
public string? ErrorMessage { get; set; }
|
||||
public DateTime StartTime { get; set; }
|
||||
public DateTime EndTime { get; set; }
|
||||
public List<string> ErrorDetails { get; set; } = new();
|
||||
public Dictionary<string, object> AdditionalInfo { get; set; } = new();
|
||||
|
||||
public TimeSpan Duration => EndTime - StartTime;
|
||||
}
|
||||
|
||||
public class DataTransferService : IDataTransferService
|
||||
{
|
||||
private readonly IScheduledProfileExecutionService _scheduledExecutionService;
|
||||
private readonly ILogger<DataTransferService> _logger;
|
||||
|
||||
public DataTransferService(
|
||||
IScheduledProfileExecutionService scheduledExecutionService,
|
||||
ILogger<DataTransferService> logger)
|
||||
{
|
||||
_scheduledExecutionService = scheduledExecutionService ?? throw new ArgumentNullException(nameof(scheduledExecutionService));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<DataTransferResult> ExecuteProfileAsync(DataCouplerProfile profile, string? sourceDatabaseOverride = null, string? destinationDatabaseOverride = null)
|
||||
{
|
||||
var result = new DataTransferResult
|
||||
{
|
||||
StartTime = DateTime.Now // Usa l'ora locale per coerenza con le schedulazioni
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Iniziando esecuzione profilo {ProfileName} (ID: {ProfileId})",
|
||||
profile.Name, profile.Id);
|
||||
|
||||
// Validazione del profilo
|
||||
var validationResult = ValidateProfile(profile);
|
||||
if (!validationResult.IsValid)
|
||||
{
|
||||
result.IsSuccess = false;
|
||||
result.ErrorMessage = validationResult.ErrorMessage;
|
||||
result.EndTime = DateTime.Now; // Usa l'ora locale per coerenza
|
||||
return result;
|
||||
}
|
||||
|
||||
// Controlla se il profilo ha file come sorgente e blocca l'esecuzione
|
||||
if (profile.SourceType?.ToLower() == "file")
|
||||
{
|
||||
result.IsSuccess = false;
|
||||
result.ErrorMessage = "I profili con file come sorgente non sono supportati nelle schedulazioni per motivi di sicurezza.";
|
||||
result.EndTime = DateTime.Now; // Usa l'ora locale per coerenza
|
||||
_logger.LogWarning("Tentativo di esecuzione di profilo con file come sorgente bloccato: {ProfileName}", profile.Name);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Applica override del database se specificati
|
||||
var profileToExecute = await ApplyDatabaseOverrides(profile, sourceDatabaseOverride, destinationDatabaseOverride);
|
||||
|
||||
// Utilizza il servizio esistente per l'esecuzione
|
||||
var executionResult = await _scheduledExecutionService.ExecuteProfileAsync(profileToExecute.Id);
|
||||
|
||||
result.IsSuccess = executionResult.Success;
|
||||
result.RecordsProcessed = executionResult.RecordsProcessed;
|
||||
result.ErrorMessage = executionResult.Success ? null : executionResult.Message;
|
||||
result.EndTime = DateTime.Now; // Usa l'ora locale per coerenza
|
||||
|
||||
if (executionResult.Success)
|
||||
{
|
||||
result.AdditionalInfo["ExecutionDuration"] = executionResult.Duration.ToString();
|
||||
_logger.LogInformation("Profilo {ProfileName} eseguito con successo. " +
|
||||
"Record processati: {RecordsProcessed}, Durata: {Duration}ms",
|
||||
profile.Name, result.RecordsProcessed, result.Duration.TotalMilliseconds);
|
||||
}
|
||||
else
|
||||
{
|
||||
result.ErrorDetails.Add(executionResult.Message);
|
||||
_logger.LogError("Errore nell'esecuzione del profilo {ProfileName}: {ErrorMessage}",
|
||||
profile.Name, executionResult.Message);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Errore durante l'esecuzione del profilo {ProfileName} (ID: {ProfileId})",
|
||||
profile.Name, profile.Id);
|
||||
|
||||
result.IsSuccess = false;
|
||||
result.ErrorMessage = ex.Message;
|
||||
result.ErrorDetails.Add($"Exception: {ex.GetType().Name} - {ex.Message}");
|
||||
if (ex.InnerException != null)
|
||||
{
|
||||
result.ErrorDetails.Add($"Inner Exception: {ex.InnerException.Message}");
|
||||
}
|
||||
result.EndTime = DateTime.Now; // Usa l'ora locale per coerenza
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<DataCouplerProfile> ApplyDatabaseOverrides(DataCouplerProfile originalProfile,
|
||||
string? sourceDatabaseOverride, string? destinationDatabaseOverride)
|
||||
{
|
||||
// Se non ci sono override, restituisce il profilo originale
|
||||
if (string.IsNullOrEmpty(sourceDatabaseOverride) && string.IsNullOrEmpty(destinationDatabaseOverride))
|
||||
{
|
||||
return originalProfile;
|
||||
}
|
||||
|
||||
// Crea una copia del profilo con gli override applicati
|
||||
// In un'implementazione reale, potresti voler creare una copia più sofisticata
|
||||
// o utilizzare un metodo di clonazione appropriato
|
||||
var profileCopy = new DataCouplerProfile
|
||||
{
|
||||
Id = originalProfile.Id,
|
||||
Name = originalProfile.Name,
|
||||
Description = originalProfile.Description,
|
||||
SourceType = originalProfile.SourceType,
|
||||
SourceCredentialId = originalProfile.SourceCredentialId,
|
||||
SourceDatabaseName = originalProfile.SourceDatabaseName,
|
||||
SourceTable = originalProfile.SourceTable,
|
||||
SourceSchema = originalProfile.SourceSchema,
|
||||
SourceCustomQuery = originalProfile.SourceCustomQuery,
|
||||
SourceFilePath = originalProfile.SourceFilePath,
|
||||
DestinationType = originalProfile.DestinationType,
|
||||
DestinationCredentialId = originalProfile.DestinationCredentialId,
|
||||
DestinationTable = originalProfile.DestinationTable,
|
||||
DestinationSchema = originalProfile.DestinationSchema,
|
||||
DestinationEndpoint = originalProfile.DestinationEndpoint,
|
||||
FieldMappingJson = originalProfile.FieldMappingJson,
|
||||
SourceKeyField = originalProfile.SourceKeyField,
|
||||
UseRecordAssociations = originalProfile.UseRecordAssociations,
|
||||
IsActive = originalProfile.IsActive,
|
||||
CreatedAt = originalProfile.CreatedAt,
|
||||
LastUsedAt = originalProfile.LastUsedAt,
|
||||
CreatedBy = originalProfile.CreatedBy
|
||||
};
|
||||
|
||||
// TODO: Implementare l'applicazione degli override del database
|
||||
// Questo richiederebbe di modificare temporaneamente la stringa di connessione
|
||||
// delle credenziali per puntare al database specificato
|
||||
|
||||
_logger.LogInformation("Applicazione override database - Source: {SourceDB}, Destination: {DestDB}",
|
||||
sourceDatabaseOverride ?? "none", destinationDatabaseOverride ?? "none");
|
||||
|
||||
return await Task.FromResult(profileCopy);
|
||||
}
|
||||
|
||||
private (bool IsValid, string? ErrorMessage) ValidateProfile(DataCouplerProfile profile)
|
||||
{
|
||||
if (profile == null)
|
||||
return (false, "Profilo non specificato");
|
||||
|
||||
if (string.IsNullOrEmpty(profile.Name))
|
||||
return (false, "Nome profilo non specificato");
|
||||
|
||||
if (string.IsNullOrEmpty(profile.SourceType))
|
||||
return (false, "Tipo sorgente non specificato");
|
||||
|
||||
if (string.IsNullOrEmpty(profile.DestinationType))
|
||||
return (false, "Tipo destinazione non specificato");
|
||||
|
||||
if (!profile.SourceCredentialId.HasValue)
|
||||
return (false, "Credenziale sorgente non specificata");
|
||||
|
||||
if (!profile.DestinationCredentialId.HasValue)
|
||||
return (false, "Credenziale destinazione non specificata");
|
||||
|
||||
// Validazioni specifiche per tipo sorgente
|
||||
if (profile.SourceType == "database")
|
||||
{
|
||||
if (string.IsNullOrEmpty(profile.SourceTable) && string.IsNullOrEmpty(profile.SourceCustomQuery))
|
||||
return (false, "Tabella sorgente o query personalizzata deve essere specificata");
|
||||
}
|
||||
else if (profile.SourceType == "file")
|
||||
{
|
||||
if (string.IsNullOrEmpty(profile.SourceFilePath))
|
||||
return (false, "Percorso file sorgente non specificato");
|
||||
}
|
||||
|
||||
// Validazioni specifiche per tipo destinazione
|
||||
if (profile.DestinationType == "database")
|
||||
{
|
||||
if (string.IsNullOrEmpty(profile.DestinationTable))
|
||||
return (false, "Tabella destinazione non specificata");
|
||||
}
|
||||
else if (profile.DestinationType == "rest")
|
||||
{
|
||||
if (string.IsNullOrEmpty(profile.DestinationEndpoint))
|
||||
return (false, "Endpoint REST destinazione non specificato");
|
||||
}
|
||||
|
||||
return (true, null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace Data_Coupler.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Servizio utility per la gestione di date, orari e formattazione
|
||||
/// Utilizza il formato 24h per coerenza con il sistema
|
||||
/// </summary>
|
||||
public static class DateTimeHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// Formato orario 24h standard utilizzato in tutto il sistema
|
||||
/// </summary>
|
||||
public const string TimeFormat24H = "HH:mm";
|
||||
|
||||
/// <summary>
|
||||
/// Formato data/ora 24h completo utilizzato per il logging e la visualizzazione
|
||||
/// </summary>
|
||||
public const string DateTimeFormat24H = "dd/MM/yyyy HH:mm:ss";
|
||||
|
||||
/// <summary>
|
||||
/// Formato data/ora 24h per i log dettagliati
|
||||
/// </summary>
|
||||
public const string DetailedDateTimeFormat24H = "dd/MM/yyyy HH:mm:ss.fff";
|
||||
|
||||
/// <summary>
|
||||
/// Cultura italiana per la formattazione (formato 24h di default)
|
||||
/// </summary>
|
||||
public static readonly CultureInfo ItalianCulture = new("it-IT");
|
||||
|
||||
/// <summary>
|
||||
/// Converte un TimeSpan in stringa formato 24h (HH:mm)
|
||||
/// </summary>
|
||||
public static string FormatTime24H(TimeSpan time)
|
||||
{
|
||||
return time.ToString(TimeFormat24H);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converte un DateTime in stringa formato 24h (dd/MM/yyyy HH:mm:ss)
|
||||
/// </summary>
|
||||
public static string FormatDateTime24H(DateTime dateTime)
|
||||
{
|
||||
return dateTime.ToString(DateTimeFormat24H, ItalianCulture);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converte un DateTime in stringa formato 24h dettagliato con millisecondi
|
||||
/// </summary>
|
||||
public static string FormatDateTimeDetailed24H(DateTime dateTime)
|
||||
{
|
||||
return dateTime.ToString(DetailedDateTimeFormat24H, ItalianCulture);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prova a parsare una stringa orario in formato 24h
|
||||
/// </summary>
|
||||
public static bool TryParseTime24H(string? timeString, out TimeSpan time)
|
||||
{
|
||||
time = default;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(timeString))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return TimeSpan.TryParseExact(timeString.Trim(), TimeFormat24H, ItalianCulture, out time);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prova a parsare una stringa data/ora in formato 24h
|
||||
/// </summary>
|
||||
public static bool TryParseDateTime24H(string? dateTimeString, out DateTime dateTime)
|
||||
{
|
||||
dateTime = default;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(dateTimeString))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return DateTime.TryParseExact(dateTimeString.Trim(), DateTimeFormat24H, ItalianCulture, DateTimeStyles.None, out dateTime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ottiene l'ora corrente locale formattata in 24h
|
||||
/// </summary>
|
||||
public static string GetCurrentTime24H()
|
||||
{
|
||||
return FormatTime24H(DateTime.Now.TimeOfDay);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ottiene la data/ora corrente locale formattata in 24h
|
||||
/// </summary>
|
||||
public static string GetCurrentDateTime24H()
|
||||
{
|
||||
return FormatDateTime24H(DateTime.Now);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Valida se una stringa rappresenta un orario valido nel formato 24h
|
||||
/// </summary>
|
||||
public static bool IsValidTime24H(string? timeString)
|
||||
{
|
||||
return TryParseTime24H(timeString, out _);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Valida se una stringa rappresenta una data/ora valida nel formato 24h
|
||||
/// </summary>
|
||||
public static bool IsValidDateTime24H(string? dateTimeString)
|
||||
{
|
||||
return TryParseDateTime24H(dateTimeString, out _);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converte un orario dal formato 12h al formato 24h se necessario
|
||||
/// </summary>
|
||||
public static string? ConvertTo24H(string? timeString)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(timeString))
|
||||
{
|
||||
return timeString;
|
||||
}
|
||||
|
||||
// Se è già in formato 24h, restituisci così com'è
|
||||
if (TryParseTime24H(timeString, out var time24))
|
||||
{
|
||||
return FormatTime24H(time24);
|
||||
}
|
||||
|
||||
// Prova a parsare dal formato 12h
|
||||
if (DateTime.TryParse(timeString.Trim(), ItalianCulture, DateTimeStyles.None, out var parsed))
|
||||
{
|
||||
return FormatTime24H(parsed.TimeOfDay);
|
||||
}
|
||||
|
||||
return null; // Formato non riconosciuto
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calcola il tempo rimanente fino al prossimo orario schedulato
|
||||
/// </summary>
|
||||
public static TimeSpan TimeUntilNextSchedule(TimeSpan scheduledTime, DateTime? referenceTime = null)
|
||||
{
|
||||
var now = referenceTime ?? DateTime.Now;
|
||||
var currentTime = now.TimeOfDay;
|
||||
|
||||
// Se l'orario programmato è già passato oggi, programma per domani
|
||||
if (currentTime > scheduledTime)
|
||||
{
|
||||
var tomorrow = now.Date.AddDays(1);
|
||||
var nextExecution = tomorrow.Add(scheduledTime);
|
||||
return nextExecution - now;
|
||||
}
|
||||
else
|
||||
{
|
||||
var today = now.Date;
|
||||
var nextExecution = today.Add(scheduledTime);
|
||||
return nextExecution - now;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ottiene il nome del giorno della settimana in italiano
|
||||
/// </summary>
|
||||
public static string GetDayOfWeekName(DayOfWeek dayOfWeek)
|
||||
{
|
||||
return dayOfWeek switch
|
||||
{
|
||||
DayOfWeek.Sunday => "Domenica",
|
||||
DayOfWeek.Monday => "Lunedì",
|
||||
DayOfWeek.Tuesday => "Martedì",
|
||||
DayOfWeek.Wednesday => "Mercoledì",
|
||||
DayOfWeek.Thursday => "Giovedì",
|
||||
DayOfWeek.Friday => "Venerdì",
|
||||
DayOfWeek.Saturday => "Sabato",
|
||||
_ => dayOfWeek.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ottiene il nome del giorno della settimana in italiano tramite indice (0=Domenica)
|
||||
/// </summary>
|
||||
public static string GetDayOfWeekName(int dayOfWeekIndex)
|
||||
{
|
||||
if (dayOfWeekIndex < 0 || dayOfWeekIndex > 6)
|
||||
{
|
||||
return "Non valido";
|
||||
}
|
||||
|
||||
return GetDayOfWeekName((DayOfWeek)dayOfWeekIndex);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace Data_Coupler.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Risultato dell'esecuzione di un profilo schedulato
|
||||
/// </summary>
|
||||
public class ProfileExecutionResult
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public string Message { get; set; } = string.Empty;
|
||||
public int RecordsProcessed { get; set; }
|
||||
public DateTime ExecutionTime { get; set; }
|
||||
public TimeSpan Duration { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interfaccia per l'esecuzione di profili schedulati
|
||||
/// </summary>
|
||||
public interface IScheduledProfileExecutionService
|
||||
{
|
||||
/// <summary>
|
||||
/// Esegue un profilo Data Coupler specificato dall'ID
|
||||
/// </summary>
|
||||
Task<ProfileExecutionResult> ExecuteProfileAsync(int profileId);
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
using CredentialManager.Models;
|
||||
using CredentialManager.Services;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Data_Coupler.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Servizio background che gestisce l'esecuzione automatica dei profili schedulati
|
||||
/// Controlla ogni minuto se ci sono profili da eseguire secondo la schedulazione impostata
|
||||
/// </summary>
|
||||
public class ScheduledExecutionBackgroundService : BackgroundService
|
||||
{
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly ILogger<ScheduledExecutionBackgroundService> _logger;
|
||||
private readonly TimeSpan _checkInterval = TimeSpan.FromMinutes(1); // Controlla ogni minuto
|
||||
|
||||
public ScheduledExecutionBackgroundService(
|
||||
IServiceProvider serviceProvider,
|
||||
ILogger<ScheduledExecutionBackgroundService> logger)
|
||||
{
|
||||
_serviceProvider = serviceProvider;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("Servizio di schedulazione automatica avviato. Controllo ogni {CheckInterval} minuti.",
|
||||
_checkInterval.TotalMinutes);
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await CheckAndExecuteScheduledProfiles();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Errore durante il controllo delle schedulazioni");
|
||||
}
|
||||
|
||||
// Attendi il prossimo controllo
|
||||
await Task.Delay(_checkInterval, stoppingToken);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Servizio di schedulazione automatica arrestato.");
|
||||
}
|
||||
|
||||
private async Task CheckAndExecuteScheduledProfiles()
|
||||
{
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
var scheduleService = scope.ServiceProvider.GetRequiredService<IProfileScheduleService>();
|
||||
var executionService = scope.ServiceProvider.GetRequiredService<IScheduledProfileExecutionService>();
|
||||
var profileService = scope.ServiceProvider.GetRequiredService<IDataCouplerProfileService>();
|
||||
|
||||
try
|
||||
{
|
||||
// Ottieni tutte le schedulazioni attive
|
||||
var activeSchedules = await scheduleService.GetActiveSchedulesAsync();
|
||||
var currentTime = DateTime.Now; // Usa l'ora locale per il confronto con le schedulazioni
|
||||
|
||||
_logger.LogDebug("Controllo schedulazioni: {ScheduleCount} schedulazioni attive alle {CurrentTime}",
|
||||
activeSchedules.Count, DateTimeHelper.FormatDateTime24H(currentTime));
|
||||
|
||||
foreach (var schedule in activeSchedules)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (ShouldExecuteSchedule(schedule, currentTime))
|
||||
{
|
||||
_logger.LogInformation("Esecuzione schedulata per profilo: {ProfileName} (Schedule: {ScheduleName})",
|
||||
schedule.Profile?.Name ?? "N/A", schedule.Name);
|
||||
|
||||
// Esegui il profilo
|
||||
var result = await executionService.ExecuteProfileAsync(schedule.ProfileId);
|
||||
|
||||
// Aggiorna la schedulazione
|
||||
await UpdateScheduleAfterExecution(scheduleService, schedule, currentTime, result.Success);
|
||||
|
||||
// Log del risultato
|
||||
if (result.Success)
|
||||
{
|
||||
_logger.LogInformation("Esecuzione schedulata completata con successo per {ProfileName}. " +
|
||||
"Record processati: {RecordsProcessed}, Durata: {Duration}ms",
|
||||
schedule.Profile?.Name ?? "N/A", result.RecordsProcessed, result.Duration.TotalMilliseconds);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogError("Esecuzione schedulata fallita per {ProfileName}: {ErrorMessage}",
|
||||
schedule.Profile?.Name ?? "N/A", result.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Errore durante l'esecuzione della schedulazione {ScheduleName} per il profilo {ProfileName}",
|
||||
schedule.Name, schedule.Profile?.Name ?? "N/A");
|
||||
|
||||
// Aggiorna la schedulazione anche in caso di errore
|
||||
try
|
||||
{
|
||||
await UpdateScheduleAfterExecution(scheduleService, schedule, currentTime, false);
|
||||
}
|
||||
catch (Exception updateEx)
|
||||
{
|
||||
_logger.LogError(updateEx, "Errore durante l'aggiornamento della schedulazione {ScheduleName} dopo l'errore",
|
||||
schedule.Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Errore durante il recupero delle schedulazioni attive");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determina se una schedulazione deve essere eseguita in base all'orario corrente
|
||||
/// </summary>
|
||||
private bool ShouldExecuteSchedule(ProfileSchedule schedule, DateTime currentTime)
|
||||
{
|
||||
if (!schedule.IsEnabled)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Controllo per evitare esecuzioni multiple nella stessa finestra temporale (tolleranza di 1 minuto)
|
||||
if (schedule.LastExecutionTime.HasValue)
|
||||
{
|
||||
var timeSinceLastExecution = currentTime - schedule.LastExecutionTime.Value;
|
||||
if (timeSinceLastExecution.TotalMinutes < 1)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return schedule.ScheduleType.ToLower() switch
|
||||
{
|
||||
"once" => ShouldExecuteOnceSchedule(schedule, currentTime),
|
||||
"daily" => ShouldExecuteDailySchedule(schedule, currentTime),
|
||||
"weekly" => ShouldExecuteWeeklySchedule(schedule, currentTime),
|
||||
"monthly" => ShouldExecuteMonthlySchedule(schedule, currentTime),
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
private bool ShouldExecuteOnceSchedule(ProfileSchedule schedule, DateTime currentTime)
|
||||
{
|
||||
if (!schedule.ScheduledDateTime.HasValue)
|
||||
{
|
||||
_logger.LogWarning("Schedulazione 'once' senza data/ora specificata: {ScheduleName}", schedule.Name);
|
||||
return false;
|
||||
}
|
||||
|
||||
var scheduledTime = schedule.ScheduledDateTime.Value;
|
||||
|
||||
// Esegui se l'orario programmato è passato e non è mai stato eseguito
|
||||
return currentTime >= scheduledTime && !schedule.LastExecutionTime.HasValue;
|
||||
}
|
||||
|
||||
private bool ShouldExecuteDailySchedule(ProfileSchedule schedule, DateTime currentTime)
|
||||
{
|
||||
if (string.IsNullOrEmpty(schedule.DailyTime))
|
||||
{
|
||||
_logger.LogWarning("Schedulazione 'daily' senza orario specificato: {ScheduleName}", schedule.Name);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!DateTimeHelper.TryParseTime24H(schedule.DailyTime, out var scheduledTime))
|
||||
{
|
||||
_logger.LogWarning("Formato orario non valido per schedulazione daily {ScheduleName}: {DailyTime} (formato richiesto: HH:mm)",
|
||||
schedule.Name, schedule.DailyTime);
|
||||
return false;
|
||||
}
|
||||
|
||||
var currentTimeOfDay = currentTime.TimeOfDay;
|
||||
var scheduledTimeOfDay = scheduledTime;
|
||||
|
||||
// Verifica se siamo nell'orario giusto (con tolleranza di ±1 minuto)
|
||||
var timeDifference = Math.Abs((currentTimeOfDay - scheduledTimeOfDay).TotalMinutes);
|
||||
|
||||
if (timeDifference > 1)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Se non è mai stato eseguito, esegui
|
||||
if (!schedule.LastExecutionTime.HasValue)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Se è stato eseguito, verifica che sia passato almeno un giorno
|
||||
var lastExecutionDate = schedule.LastExecutionTime.Value.Date;
|
||||
var currentDate = currentTime.Date;
|
||||
|
||||
return currentDate > lastExecutionDate;
|
||||
}
|
||||
|
||||
private bool ShouldExecuteWeeklySchedule(ProfileSchedule schedule, DateTime currentTime)
|
||||
{
|
||||
if (!schedule.DayOfWeek.HasValue || string.IsNullOrEmpty(schedule.DailyTime))
|
||||
{
|
||||
_logger.LogWarning("Schedulazione 'weekly' senza giorno della settimana o orario specificato: {ScheduleName}",
|
||||
schedule.Name);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!DateTimeHelper.TryParseTime24H(schedule.DailyTime, out var scheduledTime))
|
||||
{
|
||||
_logger.LogWarning("Formato orario non valido per schedulazione weekly {ScheduleName}: {DailyTime} (formato richiesto: HH:mm)",
|
||||
schedule.Name, schedule.DailyTime);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verifica il giorno della settimana (0=Domenica, 1=Lunedì, etc.)
|
||||
var currentDayOfWeek = (int)currentTime.DayOfWeek;
|
||||
if (currentDayOfWeek != schedule.DayOfWeek.Value)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verifica l'orario (con tolleranza di ±1 minuto)
|
||||
var currentTimeOfDay = currentTime.TimeOfDay;
|
||||
var timeDifference = Math.Abs((currentTimeOfDay - scheduledTime).TotalMinutes);
|
||||
|
||||
if (timeDifference > 1)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Se non è mai stato eseguito, esegui
|
||||
if (!schedule.LastExecutionTime.HasValue)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Se è stato eseguito, verifica che sia passata almeno una settimana
|
||||
var daysSinceLastExecution = (currentTime.Date - schedule.LastExecutionTime.Value.Date).TotalDays;
|
||||
|
||||
return daysSinceLastExecution >= 7;
|
||||
}
|
||||
|
||||
private bool ShouldExecuteMonthlySchedule(ProfileSchedule schedule, DateTime currentTime)
|
||||
{
|
||||
if (!schedule.DayOfMonth.HasValue || string.IsNullOrEmpty(schedule.DailyTime))
|
||||
{
|
||||
_logger.LogWarning("Schedulazione 'monthly' senza giorno del mese o orario specificato: {ScheduleName}",
|
||||
schedule.Name);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!DateTimeHelper.TryParseTime24H(schedule.DailyTime, out var scheduledTime))
|
||||
{
|
||||
_logger.LogWarning("Formato orario non valido per schedulazione monthly {ScheduleName}: {DailyTime} (formato richiesto: HH:mm)",
|
||||
schedule.Name, schedule.DailyTime);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verifica il giorno del mese
|
||||
var currentDayOfMonth = currentTime.Day;
|
||||
var scheduledDayOfMonth = schedule.DayOfMonth.Value;
|
||||
|
||||
// Gestione per mesi con meno giorni (es. 31 in febbraio diventa ultimo giorno del mese)
|
||||
var daysInCurrentMonth = DateTime.DaysInMonth(currentTime.Year, currentTime.Month);
|
||||
var effectiveScheduledDay = Math.Min(scheduledDayOfMonth, daysInCurrentMonth);
|
||||
|
||||
if (currentDayOfMonth != effectiveScheduledDay)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verifica l'orario (con tolleranza di ±1 minuto)
|
||||
var currentTimeOfDay = currentTime.TimeOfDay;
|
||||
var timeDifference = Math.Abs((currentTimeOfDay - scheduledTime).TotalMinutes);
|
||||
|
||||
if (timeDifference > 1)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Se non è mai stato eseguito, esegui
|
||||
if (!schedule.LastExecutionTime.HasValue)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Se è stato eseguito, verifica che sia passato almeno un mese
|
||||
var lastExecutionDate = schedule.LastExecutionTime.Value;
|
||||
var monthsSinceLastExecution = ((currentTime.Year - lastExecutionDate.Year) * 12) + currentTime.Month - lastExecutionDate.Month;
|
||||
|
||||
return monthsSinceLastExecution >= 1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aggiorna la schedulazione dopo l'esecuzione
|
||||
/// </summary>
|
||||
private async Task UpdateScheduleAfterExecution(IProfileScheduleService scheduleService,
|
||||
ProfileSchedule schedule, DateTime executionTime, bool wasSuccessful)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Aggiorna i dati della schedulazione
|
||||
schedule.LastExecutionTime = executionTime;
|
||||
schedule.ExecutionCount++;
|
||||
|
||||
// Calcola il prossimo tempo di esecuzione
|
||||
schedule.NextExecutionTime = CalculateNextExecutionTime(schedule, executionTime);
|
||||
|
||||
// Per schedulazioni "once", disabilita dopo l'esecuzione
|
||||
if (schedule.ScheduleType.ToLower() == "once")
|
||||
{
|
||||
schedule.IsEnabled = false;
|
||||
_logger.LogInformation("Schedulazione 'once' {ScheduleName} disabilitata dopo l'esecuzione", schedule.Name);
|
||||
}
|
||||
|
||||
await scheduleService.UpdateScheduleAsync(schedule);
|
||||
|
||||
_logger.LogDebug("Schedulazione {ScheduleName} aggiornata. Prossima esecuzione: {NextExecution}",
|
||||
schedule.Name, schedule.NextExecutionTime.HasValue ? DateTimeHelper.FormatDateTime24H(schedule.NextExecutionTime.Value) : "N/A");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Errore durante l'aggiornamento della schedulazione {ScheduleName}", schedule.Name);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calcola il prossimo tempo di esecuzione per una schedulazione
|
||||
/// </summary>
|
||||
private DateTime? CalculateNextExecutionTime(ProfileSchedule schedule, DateTime lastExecution)
|
||||
{
|
||||
if (!schedule.IsEnabled)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return schedule.ScheduleType.ToLower() switch
|
||||
{
|
||||
"once" => null, // Schedulazione singola non ha prossima esecuzione
|
||||
"daily" => CalculateNextDailyExecution(schedule, lastExecution),
|
||||
"weekly" => CalculateNextWeeklyExecution(schedule, lastExecution),
|
||||
"monthly" => CalculateNextMonthlyExecution(schedule, lastExecution),
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private DateTime? CalculateNextDailyExecution(ProfileSchedule schedule, DateTime lastExecution)
|
||||
{
|
||||
if (string.IsNullOrEmpty(schedule.DailyTime) || !DateTimeHelper.TryParseTime24H(schedule.DailyTime, out var scheduledTime))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var nextDate = lastExecution.Date.AddDays(1);
|
||||
return nextDate.Add(scheduledTime);
|
||||
}
|
||||
|
||||
private DateTime? CalculateNextWeeklyExecution(ProfileSchedule schedule, DateTime lastExecution)
|
||||
{
|
||||
if (!schedule.DayOfWeek.HasValue || string.IsNullOrEmpty(schedule.DailyTime) ||
|
||||
!DateTimeHelper.TryParseTime24H(schedule.DailyTime, out var scheduledTime))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var nextDate = lastExecution.Date.AddDays(7);
|
||||
return nextDate.Add(scheduledTime);
|
||||
}
|
||||
|
||||
private DateTime? CalculateNextMonthlyExecution(ProfileSchedule schedule, DateTime lastExecution)
|
||||
{
|
||||
if (!schedule.DayOfMonth.HasValue || string.IsNullOrEmpty(schedule.DailyTime) ||
|
||||
!DateTimeHelper.TryParseTime24H(schedule.DailyTime, out var scheduledTime))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var nextMonth = lastExecution.AddMonths(1);
|
||||
var daysInNextMonth = DateTime.DaysInMonth(nextMonth.Year, nextMonth.Month);
|
||||
var effectiveDay = Math.Min(schedule.DayOfMonth.Value, daysInNextMonth);
|
||||
|
||||
var nextDate = new DateTime(nextMonth.Year, nextMonth.Month, effectiveDay);
|
||||
return nextDate.Add(scheduledTime);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -45,6 +45,16 @@
|
||||
<span class="oi oi-layers" aria-hidden="true"></span> Gestione Profili
|
||||
</NavLink>
|
||||
</div>
|
||||
<div class="nav-item px-3">
|
||||
<NavLink class="nav-link" href="scheduling">
|
||||
<span class="oi oi-clock" aria-hidden="true"></span> Schedulazione
|
||||
</NavLink>
|
||||
</div>
|
||||
<div class="nav-item px-3">
|
||||
<NavLink class="nav-link" href="settings">
|
||||
<span class="oi oi-cog" aria-hidden="true"></span> Impostazioni
|
||||
</NavLink>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -7,4 +7,4 @@
|
||||
@using Microsoft.JSInterop
|
||||
@using Data_Coupler
|
||||
@using Data_Coupler.Shared
|
||||
@using Components
|
||||
@using Data_Coupler.Components
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"Microsoft.Hosting.Lifetime": "Information",
|
||||
"Data_Coupler.BackgroundServices": "Information",
|
||||
"System.Net.Http.HttpClient": "Warning"
|
||||
},
|
||||
"EventLog": {
|
||||
"LogLevel": {
|
||||
"Default": "Warning",
|
||||
"Data_Coupler": "Information"
|
||||
}
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"Urls": "http://*:7550",
|
||||
"WindowsService": {
|
||||
"ServiceName": "DataCouplerService",
|
||||
"DisplayName": "Data Coupler Service",
|
||||
"Description": "Servizio per l'integrazione e trasferimento dati multi-platform"
|
||||
},
|
||||
"Kestrel": {
|
||||
"Limits": {
|
||||
"KeepAliveTimeout": "00:10:00",
|
||||
"RequestHeadersTimeout": "00:05:00",
|
||||
"MaxConcurrentConnections": 100,
|
||||
"MaxConcurrentUpgradedConnections": 100
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,22 @@
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"Microsoft.Hosting.Lifetime": "Information",
|
||||
"Data_Coupler.BackgroundServices": "Information"
|
||||
},
|
||||
"EventLog": {
|
||||
"LogLevel": {
|
||||
"Default": "Warning",
|
||||
"Data_Coupler": "Information"
|
||||
}
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
"AllowedHosts": "*",
|
||||
"Urls": "http://*:7550",
|
||||
"WindowsService": {
|
||||
"ServiceName": "DataCouplerService",
|
||||
"DisplayName": "Data Coupler Service",
|
||||
"Description": "Servizio per l'integrazione e trasferimento dati multi-platform"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user