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:
2025-10-02 01:12:39 +02:00
parent b76a6760fb
commit d042863a56
71 changed files with 17860 additions and 144 deletions
@@ -57,8 +57,21 @@ public class DatabaseInitializer : IDatabaseInitializer
_logger.LogInformation("Trovate {Count} migrazioni pendenti: {Migrations}",
pendingMigrations.Count(), string.Join(", ", pendingMigrations));
await _context.Database.MigrateAsync();
_logger.LogInformation("Migrazioni applicate con successo");
try
{
await _context.Database.MigrateAsync();
_logger.LogInformation("Migrazioni applicate con successo");
}
catch (InvalidOperationException ex) when (ex.Message.Contains("PendingModelChangesWarning"))
{
_logger.LogWarning("Rilevate modifiche al modello pendenti, procedo con la creazione delle tabelle mancanti...");
// Creiamo le tabelle mancanti manualmente
await CreateScheduleExecutionHistoriesTableAsync();
await VerifyAndAddMissingColumnsAsync();
_logger.LogInformation("Tabelle e colonne mancanti create con successo");
}
}
else
{
@@ -105,6 +118,32 @@ public class DatabaseInitializer : IDatabaseInitializer
{
_logger.LogWarning(ex, "Tabella DataCouplerProfiles non accessibile");
}
// Verifica se la tabella ProfileSchedules esiste
try
{
await _context.ProfileSchedules.CountAsync();
_logger.LogInformation("Tabella ProfileSchedules verificata con successo");
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Tabella ProfileSchedules non accessibile");
}
// Verifica se la tabella ScheduleExecutionHistories esiste
try
{
await _context.ScheduleExecutionHistories.CountAsync();
_logger.LogInformation("Tabella ScheduleExecutionHistories verificata con successo");
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Tabella ScheduleExecutionHistories non accessibile, tentativo di creazione...");
await CreateScheduleExecutionHistoriesTableAsync();
}
// Verifica e aggiungi colonne mancanti per ProfileSchedules
await VerifyAndAddMissingColumnsAsync();
}
catch (Exception ex)
{
@@ -235,4 +274,105 @@ public class DatabaseInitializer : IDatabaseInitializer
throw;
}
}
private async Task CreateScheduleExecutionHistoriesTableAsync()
{
try
{
_logger.LogInformation("Creazione tabella ScheduleExecutionHistories...");
// Crea la tabella ScheduleExecutionHistories
await _context.Database.ExecuteSqlRawAsync(@"
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
)");
// Crea gli indici
await _context.Database.ExecuteSqlRawAsync(@"
CREATE INDEX IF NOT EXISTS IDX_ScheduleExecutionHistories_ScheduleId
ON ScheduleExecutionHistories (ScheduleId)");
await _context.Database.ExecuteSqlRawAsync(@"
CREATE INDEX IF NOT EXISTS IDX_ScheduleExecutionHistories_ProfileId
ON ScheduleExecutionHistories (ProfileId)");
await _context.Database.ExecuteSqlRawAsync(@"
CREATE INDEX IF NOT EXISTS IDX_ScheduleExecutionHistories_Status
ON ScheduleExecutionHistories (Status)");
await _context.Database.ExecuteSqlRawAsync(@"
CREATE INDEX IF NOT EXISTS IDX_ScheduleExecutionHistories_StartTime
ON ScheduleExecutionHistories (StartTime)");
await _context.Database.ExecuteSqlRawAsync(@"
CREATE INDEX IF NOT EXISTS IDX_ScheduleExecutionHistories_TriggerType
ON ScheduleExecutionHistories (TriggerType)");
_logger.LogInformation("Tabella ScheduleExecutionHistories creata con successo");
}
catch (Exception ex)
{
_logger.LogError(ex, "Errore durante la creazione della tabella ScheduleExecutionHistories");
_logger.LogWarning("Continuazione normale - la tabella potrebbe già esistere");
}
}
private async Task VerifyAndAddMissingColumnsAsync()
{
try
{
_logger.LogInformation("Verifica colonne mancanti per ProfileSchedules...");
// Verifica se le colonne di override database esistono
try
{
await _context.Database.ExecuteSqlRawAsync(
"SELECT SourceDatabaseOverride FROM ProfileSchedules LIMIT 1");
}
catch
{
_logger.LogInformation("Aggiunta colonna SourceDatabaseOverride...");
await _context.Database.ExecuteSqlRawAsync(
"ALTER TABLE ProfileSchedules ADD COLUMN SourceDatabaseOverride TEXT");
}
try
{
await _context.Database.ExecuteSqlRawAsync(
"SELECT DestinationDatabaseOverride FROM ProfileSchedules LIMIT 1");
}
catch
{
_logger.LogInformation("Aggiunta colonna DestinationDatabaseOverride...");
await _context.Database.ExecuteSqlRawAsync(
"ALTER TABLE ProfileSchedules ADD COLUMN DestinationDatabaseOverride TEXT");
}
_logger.LogInformation("Verifica colonne completata");
}
catch (Exception ex)
{
_logger.LogError(ex, "Errore durante la verifica/aggiunta colonne");
_logger.LogWarning("Continuazione normale - le colonne potrebbero già esistere");
}
}
}
@@ -0,0 +1,27 @@
using CredentialManager.Models;
namespace CredentialManager.Services;
/// <summary>
/// Servizio per la gestione delle schedulazioni dei profili
/// </summary>
public interface IProfileScheduleService
{
Task<List<ProfileSchedule>> GetAllSchedulesAsync();
Task<ProfileSchedule?> GetScheduleByIdAsync(int id);
Task<ProfileSchedule> CreateScheduleAsync(ProfileSchedule schedule);
Task<ProfileSchedule> UpdateScheduleAsync(ProfileSchedule schedule);
Task<bool> DeleteScheduleAsync(int id);
Task<List<ProfileSchedule>> GetActiveSchedulesAsync();
Task<List<ProfileSchedule>> GetPendingExecutionsAsync();
Task<bool> UpdateExecutionStatusAsync(int scheduleId, string status, string? message = null, int? recordCount = null);
Task UpdateNextExecutionTimeAsync(int scheduleId);
Task<List<DataCouplerProfile>> GetAvailableProfilesAsync();
// Metodi per lo storico delle esecuzioni
Task<ScheduleExecutionHistory> CreateExecutionHistoryAsync(ScheduleExecutionHistory history);
Task<ScheduleExecutionHistory> UpdateExecutionHistoryAsync(ScheduleExecutionHistory history);
Task<List<ScheduleExecutionHistory>> GetExecutionHistoryAsync(int scheduleId, int? limit = null);
Task<List<ScheduleExecutionHistory>> GetRecentExecutionsAsync(int limit = 50);
Task<ScheduleExecutionHistory?> GetExecutionByIdAsync(int executionId);
}
@@ -0,0 +1,351 @@
using CredentialManager.Data;
using CredentialManager.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace CredentialManager.Services;
public class ProfileScheduleService : IProfileScheduleService
{
private readonly CredentialDbContext _context;
private readonly ILogger<ProfileScheduleService> _logger;
public ProfileScheduleService(CredentialDbContext context, ILogger<ProfileScheduleService> logger)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<List<ProfileSchedule>> GetAllSchedulesAsync()
{
try
{
return await _context.ProfileSchedules
.Include(s => s.Profile)
.OrderBy(s => s.Name)
.ToListAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "Errore nel recupero di tutte le schedulazioni");
throw;
}
}
public async Task<ProfileSchedule?> GetScheduleByIdAsync(int id)
{
try
{
return await _context.ProfileSchedules
.Include(s => s.Profile)
.FirstOrDefaultAsync(s => s.Id == id);
}
catch (Exception ex)
{
_logger.LogError(ex, "Errore nel recupero della schedulazione con ID {Id}", id);
throw;
}
}
public async Task<ProfileSchedule> CreateScheduleAsync(ProfileSchedule schedule)
{
try
{
schedule.CreatedAt = DateTime.UtcNow;
schedule.UpdatedAt = DateTime.UtcNow;
schedule.CreatedBy = Environment.UserName;
// Calcola la prossima esecuzione
schedule.NextExecutionTime = schedule.CalculateNextExecution();
_context.ProfileSchedules.Add(schedule);
await _context.SaveChangesAsync();
_logger.LogInformation("Schedulazione creata: {Name} per il profilo {ProfileId}", schedule.Name, schedule.ProfileId);
return schedule;
}
catch (Exception ex)
{
_logger.LogError(ex, "Errore nella creazione della schedulazione {Name}", schedule.Name);
throw;
}
}
public async Task<ProfileSchedule> UpdateScheduleAsync(ProfileSchedule schedule)
{
try
{
var existingSchedule = await _context.ProfileSchedules.FindAsync(schedule.Id);
if (existingSchedule == null)
throw new InvalidOperationException($"Schedulazione con ID {schedule.Id} non trovata");
// Aggiorna i campi
existingSchedule.Name = schedule.Name;
existingSchedule.Description = schedule.Description;
existingSchedule.ProfileId = schedule.ProfileId;
existingSchedule.IsEnabled = schedule.IsEnabled;
existingSchedule.ScheduleType = schedule.ScheduleType;
existingSchedule.ScheduledDateTime = schedule.ScheduledDateTime;
existingSchedule.DailyTime = schedule.DailyTime;
existingSchedule.DayOfWeek = schedule.DayOfWeek;
existingSchedule.DayOfMonth = schedule.DayOfMonth;
existingSchedule.IntervalValue = schedule.IntervalValue;
existingSchedule.IntervalUnit = schedule.IntervalUnit;
existingSchedule.IsActive = schedule.IsActive;
existingSchedule.UpdatedAt = DateTime.UtcNow;
// Ricalcola la prossima esecuzione
existingSchedule.NextExecutionTime = existingSchedule.CalculateNextExecution();
await _context.SaveChangesAsync();
_logger.LogInformation("Schedulazione aggiornata: {Name}", existingSchedule.Name);
return existingSchedule;
}
catch (Exception ex)
{
_logger.LogError(ex, "Errore nell'aggiornamento della schedulazione con ID {Id}", schedule.Id);
throw;
}
}
public async Task<bool> DeleteScheduleAsync(int id)
{
try
{
var schedule = await _context.ProfileSchedules.FindAsync(id);
if (schedule == null)
return false;
_context.ProfileSchedules.Remove(schedule);
await _context.SaveChangesAsync();
_logger.LogInformation("Schedulazione eliminata: {Name} (ID: {Id})", schedule.Name, id);
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Errore nell'eliminazione della schedulazione con ID {Id}", id);
throw;
}
}
public async Task<List<ProfileSchedule>> GetActiveSchedulesAsync()
{
try
{
return await _context.ProfileSchedules
.Include(s => s.Profile)
.Where(s => s.IsActive && s.IsEnabled)
.OrderBy(s => s.NextExecutionTime)
.ToListAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "Errore nel recupero delle schedulazioni attive");
throw;
}
}
public async Task<List<ProfileSchedule>> GetPendingExecutionsAsync()
{
try
{
var now = DateTime.Now;
return await _context.ProfileSchedules
.Include(s => s.Profile)
.Where(s => s.IsActive &&
s.IsEnabled &&
s.NextExecutionTime.HasValue &&
s.NextExecutionTime <= now &&
s.LastExecutionStatus != "running")
.OrderBy(s => s.NextExecutionTime)
.ToListAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "Errore nel recupero delle schedulazioni in attesa di esecuzione");
throw;
}
}
public async Task<bool> UpdateExecutionStatusAsync(int scheduleId, string status, string? message = null, int? recordCount = null)
{
try
{
var schedule = await _context.ProfileSchedules.FindAsync(scheduleId);
if (schedule == null)
return false;
schedule.LastExecutionStatus = status;
schedule.LastExecutionMessage = message;
schedule.LastExecutionTime = DateTime.Now;
if (recordCount.HasValue)
schedule.LastExecutionRecordCount = recordCount;
if (status == "success" || status == "failed")
{
schedule.ExecutionCount++;
// Calcola la prossima esecuzione solo se completata (successo o errore)
schedule.NextExecutionTime = schedule.CalculateNextExecution();
}
await _context.SaveChangesAsync();
_logger.LogInformation("Status esecuzione aggiornato per schedulazione {ScheduleId}: {Status}", scheduleId, status);
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Errore nell'aggiornamento dello status per schedulazione {ScheduleId}", scheduleId);
throw;
}
}
public async Task UpdateNextExecutionTimeAsync(int scheduleId)
{
try
{
var schedule = await _context.ProfileSchedules.FindAsync(scheduleId);
if (schedule == null)
return;
// Per schedulazioni a intervallo, calcola dalla ultima esecuzione
if (schedule.ScheduleType == "interval")
{
schedule.NextExecutionTime = schedule.CalculateNextExecutionFromLast();
}
else
{
schedule.NextExecutionTime = schedule.CalculateNextExecution();
}
await _context.SaveChangesAsync();
_logger.LogDebug("Prossima esecuzione aggiornata per schedulazione {ScheduleId}: {NextExecution} (tipo: {Type})",
scheduleId, schedule.NextExecutionTime, schedule.ScheduleType);
}
catch (Exception ex)
{
_logger.LogError(ex, "Errore nell'aggiornamento della prossima esecuzione per schedulazione {ScheduleId}", scheduleId);
throw;
}
}
public async Task<List<DataCouplerProfile>> GetAvailableProfilesAsync()
{
try
{
return await _context.DataCouplerProfiles
.Where(p => p.IsActive)
.OrderBy(p => p.Name)
.ToListAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "Errore nel recupero dei profili disponibili");
throw;
}
}
// Implementazione metodi per lo storico delle esecuzioni
public async Task<ScheduleExecutionHistory> CreateExecutionHistoryAsync(ScheduleExecutionHistory history)
{
try
{
history.CreatedAt = DateTime.UtcNow;
_context.ScheduleExecutionHistories.Add(history);
await _context.SaveChangesAsync();
_logger.LogInformation("Storico esecuzione creato: ID {HistoryId} per schedulazione {ScheduleId}",
history.Id, history.ScheduleId);
return history;
}
catch (Exception ex)
{
_logger.LogError(ex, "Errore nella creazione dello storico esecuzione per schedulazione {ScheduleId}",
history.ScheduleId);
throw;
}
}
public async Task<ScheduleExecutionHistory> UpdateExecutionHistoryAsync(ScheduleExecutionHistory history)
{
try
{
_context.ScheduleExecutionHistories.Update(history);
await _context.SaveChangesAsync();
_logger.LogDebug("Storico esecuzione aggiornato: ID {HistoryId}", history.Id);
return history;
}
catch (Exception ex)
{
_logger.LogError(ex, "Errore nell'aggiornamento dello storico esecuzione ID {HistoryId}", history.Id);
throw;
}
}
public async Task<List<ScheduleExecutionHistory>> GetExecutionHistoryAsync(int scheduleId, int? limit = null)
{
try
{
var query = _context.ScheduleExecutionHistories
.Where(h => h.ScheduleId == scheduleId)
.OrderByDescending(h => h.StartTime);
if (limit.HasValue)
{
query = (IOrderedQueryable<ScheduleExecutionHistory>)query.Take(limit.Value);
}
return await query.ToListAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "Errore nel recupero dello storico per schedulazione {ScheduleId}", scheduleId);
throw;
}
}
public async Task<List<ScheduleExecutionHistory>> GetRecentExecutionsAsync(int limit = 50)
{
try
{
return await _context.ScheduleExecutionHistories
.Include(h => h.Schedule)
.OrderByDescending(h => h.StartTime)
.Take(limit)
.ToListAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "Errore nel recupero delle esecuzioni recenti");
throw;
}
}
public async Task<ScheduleExecutionHistory?> GetExecutionByIdAsync(int executionId)
{
try
{
return await _context.ScheduleExecutionHistories
.Include(h => h.Schedule)
.FirstOrDefaultAsync(h => h.Id == executionId);
}
catch (Exception ex)
{
_logger.LogError(ex, "Errore nel recupero dell'esecuzione ID {ExecutionId}", executionId);
throw;
}
}
}