From d042863a56595de664c963b9e04e14abfc96e431 Mon Sep 17 00:00:00 2001 From: Alessio Dal Santo Date: Thu, 2 Oct 2025 01:12:39 +0200 Subject: [PATCH] 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 --- ADVANCED_SCHEDULING_SYSTEM.md | 666 ++++++++++ AGENTS.md | 238 +++- ASSOCIATION_MANAGEMENT_FIX.md | 256 ++++ CredentialManager/Data/CredentialDbContext.cs | 59 + ...0924155833_AddProfileSchedules.Designer.cs | 443 +++++++ .../20250924155833_AddProfileSchedules.cs | 83 ++ ...50924161239_AddProfileSchedule.Designer.cs | 436 +++++++ .../20250924161239_AddProfileSchedule.cs | 225 ++++ ...31_AddScheduleExecutionHistory.Designer.cs | 544 +++++++++ ...50924173231_AddScheduleExecutionHistory.cs | 105 ++ ...02_AddIntervalSchedulingFields.Designer.cs | 551 +++++++++ ...51001224302_AddIntervalSchedulingFields.cs | 39 + .../CredentialDbContextModelSnapshot.cs | 206 ++++ CredentialManager/Models/ProfileSchedule.cs | 227 ++++ .../Models/ScheduleExecutionHistory.cs | 101 ++ .../Services/DatabaseInitializer.cs | 144 ++- .../Services/IProfileScheduleService.cs | 27 + .../Services/ProfileScheduleService.cs | 351 ++++++ CredentialManager/design_time_temp.db | Bin 118784 -> 155648 bytes DataConnection/DB/EF/EFCoreDatabaseManager.cs | 214 +++- .../DB/Interfaces/IDatabaseManager.cs | 2 +- .../SalesforceServiceClient.cs | 711 ++++++++++- .../ScheduleExecutorService.cs | 145 +++ .../BackgroundServices/ScheduledJobService.cs | 327 +++++ Data_Coupler/Components/BackupTab.razor | 558 +++++++++ Data_Coupler/Components/MaintenanceTab.razor | 573 +++++++++ Data_Coupler/Components/SecurityTab.razor | 346 ++++++ Data_Coupler/Components/SystemTab.razor | 200 +++ .../Extensions/DataCoupler/RESTMethod.cs | 15 +- Data_Coupler/Models/BackupModels.cs | 334 +++++ Data_Coupler/Pages/DataCoupler.razor.cs | 38 +- Data_Coupler/Pages/Scheduling.razor | 350 ++++++ Data_Coupler/Pages/Scheduling.razor.cs | 444 +++++++ Data_Coupler/Pages/SchedulingHistory.razor | 256 ++++ Data_Coupler/Pages/SchedulingHistory.razor.cs | 152 +++ Data_Coupler/Pages/SettingsPage.razor | 229 ++++ Data_Coupler/Pages/_Host.cshtml | 1 + Data_Coupler/Program.cs | 133 +- .../Scripts/UpdateSchedulingDatabase.sql | 44 + Data_Coupler/Services/BackupService.cs | 733 +++++++++++ Data_Coupler/Services/DataTransferService.cs | 210 ++++ Data_Coupler/Services/DateTimeHelper.cs | 195 +++ .../IScheduledProfileExecutionService.cs | 24 + .../ScheduledExecutionBackgroundService.cs | 388 ++++++ .../ScheduledProfileExecutionService.cs | 1069 +++++++++++++++++ Data_Coupler/Shared/NavMenu.razor | 10 + Data_Coupler/_Imports.razor | 2 +- Data_Coupler/appsettings.Production.json | 32 + Data_Coupler/appsettings.json | 18 +- DatabaseFixer.cs | 59 + HASH_ALIGNMENT_SUMMARY.md | 247 ++++ HASH_CALCULATION_ALIGNMENT.md | 248 ++++ HASH_CALCULATION_FAQ.md | 339 ++++++ HASH_CALCULATION_FINAL_FIX.md | 485 ++++++++ HASH_LOGIC_ALIGNMENT.md | 326 +++++ IMPLEMENTAZIONE_FINALE_SCHEDULING.md | 503 ++++++++ OTTIMIZZAZIONE_DISCOVERY_COMPLETATA.md | 67 ++ PROJECT_COMPLETION_CHECKLIST.md | 295 +++++ README_SCHEDULING_INTERVALLI.md | 250 ++++ SALESFORCE_BATCH_EXTRACTION_IMPROVEMENTS.md | 246 ++++ SCHEDULING_COMPLETION_REPORT.md | 263 ++++ SCHEDULING_SUMMARY.md | 140 +++ SCHEDULING_UI_GUIDE.md | 436 +++++++ SCHEDULING_UI_IMPLEMENTATION.md | 497 ++++++++ SCHEDULING_USER_GUIDE.md | 462 +++++++ Scripts/HashCalculationTest.cs | 218 ++++ Scripts/build-and-deploy.ps1 | 98 ++ Scripts/install-service.ps1 | 90 ++ Scripts/uninstall-service.ps1 | 68 ++ WINDOWS_SERVICE_DEPLOYMENT.md | 207 ++++ fix-database.sql | 6 + 71 files changed, 17860 insertions(+), 144 deletions(-) create mode 100644 ADVANCED_SCHEDULING_SYSTEM.md create mode 100644 ASSOCIATION_MANAGEMENT_FIX.md create mode 100644 CredentialManager/Migrations/20250924155833_AddProfileSchedules.Designer.cs create mode 100644 CredentialManager/Migrations/20250924155833_AddProfileSchedules.cs create mode 100644 CredentialManager/Migrations/20250924161239_AddProfileSchedule.Designer.cs create mode 100644 CredentialManager/Migrations/20250924161239_AddProfileSchedule.cs create mode 100644 CredentialManager/Migrations/20250924173231_AddScheduleExecutionHistory.Designer.cs create mode 100644 CredentialManager/Migrations/20250924173231_AddScheduleExecutionHistory.cs create mode 100644 CredentialManager/Migrations/20251001224302_AddIntervalSchedulingFields.Designer.cs create mode 100644 CredentialManager/Migrations/20251001224302_AddIntervalSchedulingFields.cs create mode 100644 CredentialManager/Models/ProfileSchedule.cs create mode 100644 CredentialManager/Models/ScheduleExecutionHistory.cs create mode 100644 CredentialManager/Services/IProfileScheduleService.cs create mode 100644 CredentialManager/Services/ProfileScheduleService.cs create mode 100644 Data_Coupler/BackgroundServices/ScheduleExecutorService.cs create mode 100644 Data_Coupler/BackgroundServices/ScheduledJobService.cs create mode 100644 Data_Coupler/Components/BackupTab.razor create mode 100644 Data_Coupler/Components/MaintenanceTab.razor create mode 100644 Data_Coupler/Components/SecurityTab.razor create mode 100644 Data_Coupler/Components/SystemTab.razor create mode 100644 Data_Coupler/Models/BackupModels.cs create mode 100644 Data_Coupler/Pages/Scheduling.razor create mode 100644 Data_Coupler/Pages/Scheduling.razor.cs create mode 100644 Data_Coupler/Pages/SchedulingHistory.razor create mode 100644 Data_Coupler/Pages/SchedulingHistory.razor.cs create mode 100644 Data_Coupler/Pages/SettingsPage.razor create mode 100644 Data_Coupler/Scripts/UpdateSchedulingDatabase.sql create mode 100644 Data_Coupler/Services/BackupService.cs create mode 100644 Data_Coupler/Services/DataTransferService.cs create mode 100644 Data_Coupler/Services/DateTimeHelper.cs create mode 100644 Data_Coupler/Services/IScheduledProfileExecutionService.cs create mode 100644 Data_Coupler/Services/ScheduledExecutionBackgroundService.cs create mode 100644 Data_Coupler/Services/ScheduledProfileExecutionService.cs create mode 100644 Data_Coupler/appsettings.Production.json create mode 100644 DatabaseFixer.cs create mode 100644 HASH_ALIGNMENT_SUMMARY.md create mode 100644 HASH_CALCULATION_ALIGNMENT.md create mode 100644 HASH_CALCULATION_FAQ.md create mode 100644 HASH_CALCULATION_FINAL_FIX.md create mode 100644 HASH_LOGIC_ALIGNMENT.md create mode 100644 IMPLEMENTAZIONE_FINALE_SCHEDULING.md create mode 100644 OTTIMIZZAZIONE_DISCOVERY_COMPLETATA.md create mode 100644 PROJECT_COMPLETION_CHECKLIST.md create mode 100644 README_SCHEDULING_INTERVALLI.md create mode 100644 SALESFORCE_BATCH_EXTRACTION_IMPROVEMENTS.md create mode 100644 SCHEDULING_COMPLETION_REPORT.md create mode 100644 SCHEDULING_SUMMARY.md create mode 100644 SCHEDULING_UI_GUIDE.md create mode 100644 SCHEDULING_UI_IMPLEMENTATION.md create mode 100644 SCHEDULING_USER_GUIDE.md create mode 100644 Scripts/HashCalculationTest.cs create mode 100644 Scripts/build-and-deploy.ps1 create mode 100644 Scripts/install-service.ps1 create mode 100644 Scripts/uninstall-service.ps1 create mode 100644 WINDOWS_SERVICE_DEPLOYMENT.md create mode 100644 fix-database.sql diff --git a/ADVANCED_SCHEDULING_SYSTEM.md b/ADVANCED_SCHEDULING_SYSTEM.md new file mode 100644 index 0000000..3acc8e2 --- /dev/null +++ b/ADVANCED_SCHEDULING_SYSTEM.md @@ -0,0 +1,666 @@ +# Sistema di Schedulazione Avanzata - Data Coupler + +## ๐Ÿ“‹ Panoramica + +**Data**: 2 Ottobre 2025 +**Feature**: Sistema di schedulazione completo con supporto per intervalli personalizzati +**Versione**: 2.0 + +--- + +## ๐Ÿš€ **Funzionalitร  Implementate** + +### Tipi di Schedulazione Supportati + +1. **โœ… Una Volta (Once)** + - Esecuzione singola a una data/ora specifica + - Ideale per migrazioni one-time o test + +2. **โœ… Giornaliera (Daily)** + - Esecuzione ogni giorno a un orario specifico + - Esempio: Ogni giorno alle 02:00 + +3. **โœ… Settimanale (Weekly)** + - Esecuzione ogni settimana in un giorno specifico + - Esempio: Ogni Lunedรฌ alle 08:00 + +4. **โœ… Mensile (Monthly)** + - Esecuzione ogni mese in un giorno specifico + - Esempio: Il giorno 1 di ogni mese alle 00:00 + +5. **๐Ÿ†• A Intervalli (Interval)** - NUOVA! + - Esecuzione ricorrente ogni N unitร  di tempo + - Supporta: + - **Secondi**: Ogni N secondi (utile per test/demo) + - **Minuti**: Ogni N minuti (es. ogni 5, 10, 15, 30 minuti) + - **Ore**: Ogni N ore (es. ogni 1, 2, 4, 6, 12 ore) + - **Giorni**: Ogni N giorni (es. ogni 2, 3, 7 giorni) + - **Settimane**: Ogni N settimane (es. ogni 2, 4 settimane) + - **Mesi**: Ogni N mesi (es. ogni 2, 3, 6 mesi) + +--- + +## ๐Ÿ”ง **Modifiche Implementate** + +### 1. Modello Dati - ProfileSchedule + +**File**: `CredentialManager/Models/ProfileSchedule.cs` + +#### Nuovi Campi + +```csharp +// Configurazione per schedulazioni a intervalli +public int? IntervalValue { get; set; } // Valore dell'intervallo (es. 5, 10, 30) +public string? IntervalUnit { get; set; } // "seconds", "minutes", "hours", "days", "weeks", "months" +``` + +#### Nuovi Metodi + +```csharp +/// +/// Calcola la prossima esecuzione basandosi sulla data/ora base fornita +/// Per intervalli, aggiunge l'intervallo alla data base +/// +public DateTime? CalculateNextExecutionFromLast() + +/// +/// Calcola la prossima esecuzione aggiungendo l'intervallo alla data base +/// +private DateTime CalculateNextInterval(DateTime baseTime) + +/// +/// Ottiene una descrizione leggibile della schedulazione +/// +public string GetScheduleDescription() +``` + +#### Esempi di Utilizzo + +```csharp +// Ogni 5 minuti +var schedule = new ProfileSchedule +{ + ScheduleType = "interval", + IntervalValue = 5, + IntervalUnit = "minutes" +}; +// Descrizione: "Ogni 5 minuti" + +// Ogni 2 ore +var schedule = new ProfileSchedule +{ + ScheduleType = "interval", + IntervalValue = 2, + IntervalUnit = "hours" +}; +// Descrizione: "Ogni 2 ore" + +// Ogni 30 secondi (utile per test) +var schedule = new ProfileSchedule +{ + ScheduleType = "interval", + IntervalValue = 30, + IntervalUnit = "seconds" +}; +// Descrizione: "Ogni 30 secondi" +``` + +--- + +### 2. Background Service - ScheduledJobService + +**File**: `Data_Coupler/BackgroundServices/ScheduledJobService.cs` + +#### Miglioramenti Principali + +##### โœ… Intervallo di Controllo Ridotto + +```csharp +// PRIMA: TimeSpan.FromMinutes(1) // Controllo ogni minuto +// DOPO: TimeSpan.FromSeconds(30) // Controllo ogni 30 secondi +``` + +**Motivazione**: Supportare schedulazioni a intervalli brevi (secondi/minuti) richiede controlli piรน frequenti. + +##### โœ… Esecuzione Parallela + +```csharp +// Esegue piรน schedulazioni contemporaneamente +_ = Task.Run(async () => +{ + await ExecuteScheduleAsync(schedule, cancellationToken); +}, cancellationToken); +``` + +**Benefici**: +- Piรน schedulazioni possono essere eseguite simultaneamente +- Non blocca altre schedulazioni se una รจ lenta +- Migliora throughput complessivo + +##### โœ… Tracking Esecuzioni + +```csharp +private readonly Dictionary _runningSchedules = new(); + +private bool IsScheduleRunning(int scheduleId) +private void MarkScheduleAsRunning(int scheduleId) +private void MarkScheduleAsCompleted(int scheduleId) +private void CleanupRunningSchedules() // Rimuove schedulazioni bloccate dopo 1 ora +``` + +**Protezioni**: +- Previene esecuzioni duplicate della stessa schedulazione +- Rileva e pulisce schedulazioni bloccate (timeout 1 ora) +- Thread-safe con lock + +##### โœ… Tolleranza Temporale Adattiva + +```csharp +var tolerance = schedule.ScheduleType == "interval" + ? TimeSpan.FromSeconds(30) // 30 secondi per intervalli + : TimeSpan.FromMinutes(1); // 1 minuto per altre schedulazioni +``` + +**Motivazione**: Schedulazioni a intervalli necessitano tolleranza piรน stretta per precisione. + +--- + +### 3. Service Layer - ProfileScheduleService + +**File**: `CredentialManager/Services/ProfileScheduleService.cs` + +#### Aggiornamenti + +```csharp +public async Task UpdateNextExecutionTimeAsync(int scheduleId) +{ + var schedule = await _context.ProfileSchedules.FindAsync(scheduleId); + + // Per schedulazioni a intervallo, calcola dalla ultima esecuzione + if (schedule.ScheduleType == "interval") + { + schedule.NextExecutionTime = schedule.CalculateNextExecutionFromLast(); + } + else + { + schedule.NextExecutionTime = schedule.CalculateNextExecution(); + } + + await _context.SaveChangesAsync(); +} +``` + +**Differenza Chiave**: +- **Intervalli**: Calcola da `LastExecutionTime` (tempo trascorso + intervallo) +- **Altri tipi**: Calcola da `Now` (prossimo orario programmato) + +--- + +### 4. Database Migration + +**File**: `CredentialManager/Migrations/[timestamp]_AddIntervalSchedulingFields.cs` + +#### Modifiche Schema + +```sql +ALTER TABLE ProfileSchedules +ADD IntervalValue INT NULL; + +ALTER TABLE ProfileSchedules +ADD IntervalUnit NVARCHAR(20) NULL; +``` + +**Compatibilitร **: Campi nullable, schedulazioni esistenti non impattate. + +--- + +## ๐Ÿ“Š **Esempi Pratici di Utilizzo** + +### Caso 1: Sincronizzazione Frequente (Ogni 5 Minuti) + +```json +{ + "Name": "Sincronizzazione Clienti Frequente", + "ProfileId": 1, + "ScheduleType": "interval", + "IntervalValue": 5, + "IntervalUnit": "minutes", + "IsEnabled": true +} +``` + +**Esecuzione**: +``` +10:00:00 - Prima esecuzione +10:05:00 - Seconda esecuzione (+5 minuti) +10:10:00 - Terza esecuzione (+5 minuti) +10:15:00 - Quarta esecuzione (+5 minuti) +... +``` + +--- + +### Caso 2: Backup Orario + +```json +{ + "Name": "Backup Orario Prodotti", + "ProfileId": 2, + "ScheduleType": "interval", + "IntervalValue": 1, + "IntervalUnit": "hours", + "IsEnabled": true +} +``` + +**Esecuzione**: +``` +08:00:00 - Prima esecuzione +09:00:00 - Seconda esecuzione (+1 ora) +10:00:00 - Terza esecuzione (+1 ora) +11:00:00 - Quarta esecuzione (+1 ora) +... +``` + +--- + +### Caso 3: Test Rapido (Ogni 30 Secondi) + +```json +{ + "Name": "Test Sync Veloce", + "ProfileId": 3, + "ScheduleType": "interval", + "IntervalValue": 30, + "IntervalUnit": "seconds", + "IsEnabled": true +} +``` + +**Esecuzione**: +``` +14:30:00 - Prima esecuzione +14:30:30 - Seconda esecuzione (+30 secondi) +14:31:00 - Terza esecuzione (+30 secondi) +14:31:30 - Quarta esecuzione (+30 secondi) +... +``` + +โš ๏ธ **Attenzione**: Intervalli molto brevi (<1 minuto) dovrebbero essere usati solo per test o scenari specifici. + +--- + +### Caso 4: Sincronizzazione Bi-Settimanale + +```json +{ + "Name": "Sync Archivio Bi-Settimanale", + "ProfileId": 4, + "ScheduleType": "interval", + "IntervalValue": 2, + "IntervalUnit": "weeks", + "IsEnabled": true +} +``` + +**Esecuzione**: +``` +01/10/2025 00:00 - Prima esecuzione +15/10/2025 00:00 - Seconda esecuzione (+2 settimane) +29/10/2025 00:00 - Terza esecuzione (+2 settimane) +12/11/2025 00:00 - Quarta esecuzione (+2 settimane) +... +``` + +--- + +## ๐ŸŽฏ **Logica di Schedulazione** + +### Calcolo Prossima Esecuzione + +#### Per Schedulazioni a Intervallo + +``` +NextExecutionTime = LastExecutionTime + Intervallo + +Esempio: +- LastExecutionTime = 14:30:00 +- IntervalValue = 15 +- IntervalUnit = "minutes" +- NextExecutionTime = 14:30:00 + 15 minuti = 14:45:00 +``` + +**Comportamento Primo Avvio**: +``` +Se LastExecutionTime รจ NULL (mai eseguita): + NextExecutionTime = DateTime.Now + Intervallo +``` + +#### Per Altre Schedulazioni + +``` +NextExecutionTime = Prossimo Orario Programmato + +Esempio Daily: +- DailyTime = "02:00" +- Now = 14:30 +- NextExecutionTime = Domani alle 02:00 +``` + +--- + +### Controllo Esecuzioni + +``` +Ogni 30 secondi il ScheduledJobService: +1. Recupera tutte le schedulazioni attive +2. Filtra quelle con NextExecutionTime <= Now + Tolleranza +3. Verifica che non siano giร  in esecuzione +4. Le esegue in parallelo (Task.Run) +5. Aggiorna NextExecutionTime dopo completamento +``` + +**Tolleranza Temporale**: +- Intervalli: ยฑ30 secondi +- Altri tipi: ยฑ1 minuto + +--- + +## โšก **Performance e Ottimizzazioni** + +### Esecuzione Parallela + +```csharp +// Piรน schedulazioni possono essere eseguite contemporaneamente +foreach (var schedule in pendingSchedules) +{ + _ = Task.Run(async () => + { + await ExecuteScheduleAsync(schedule, cancellationToken); + }, cancellationToken); +} +``` + +**Vantaggi**: +- โœ… Throughput migliorato +- โœ… Schedulazioni lente non bloccano altre +- โœ… Migliore utilizzo risorse + +**Protezioni**: +- โœ… Tracking per evitare duplicati +- โœ… Timeout dopo 1 ora (pulizia automatica) +- โœ… Gestione errori isolata per schedulazione + +--- + +### Controlli Piรน Frequenti + +```csharp +// Controllo ogni 30 secondi invece di 1 minuto +private TimeSpan _checkInterval = TimeSpan.FromSeconds(30); +``` + +**Motivazione**: +- Supportare intervalli brevi (es. ogni 30 secondi, ogni minuto) +- Maggiore precisione temporale +- Impatto trascurabile su performance (query leggere) + +--- + +## ๐Ÿ” **Monitoraggio e Debug** + +### Logging Dettagliato + +```csharp +// Ogni controllo (LogTrace - solo se abilitato) +_logger.LogTrace("Nessuna schedulazione in sospeso trovata"); + +// Esecuzioni trovate (LogInformation) +_logger.LogInformation("Trovate {Count} schedulazioni da eseguire", count); + +// Schedulazioni giร  running (LogDebug) +_logger.LogDebug("Schedulazione {ScheduleId} giร  in esecuzione, salto", id); + +// Aggiornamento prossima esecuzione (LogDebug) +_logger.LogDebug("Prossima esecuzione aggiornata: {NextExecution} (tipo: {Type})", + nextTime, scheduleType); + +// Completamento con successo (LogInformation) +_logger.LogInformation("Schedulazione {ScheduleId} eseguita: {Records} record, {Duration}s", + id, records, duration); + +// Errori (LogError) +_logger.LogError(ex, "Errore durante l'esecuzione schedulazione {ScheduleId}", id); +``` + +--- + +### Query Monitoraggio + +```sql +-- Schedulazioni attive per tipo +SELECT + ScheduleType, + COUNT(*) as Total, + SUM(CASE WHEN IsEnabled = 1 THEN 1 ELSE 0 END) as Enabled +FROM ProfileSchedules +WHERE IsActive = 1 +GROUP BY ScheduleType; + +-- Schedulazioni a intervallo con dettagli +SELECT + Id, + Name, + IntervalValue, + IntervalUnit, + CONCAT(IntervalValue, ' ', IntervalUnit) as Interval, + LastExecutionTime, + NextExecutionTime, + LastExecutionStatus +FROM ProfileSchedules +WHERE ScheduleType = 'interval' + AND IsEnabled = 1 +ORDER BY NextExecutionTime; + +-- Statistiche esecuzioni ultima ora +SELECT + s.Name, + s.ScheduleType, + COUNT(h.Id) as ExecutionCount, + AVG(DATEDIFF(SECOND, h.StartTime, h.EndTime)) as AvgDurationSeconds, + SUM(h.RecordsProcessed) as TotalRecords +FROM ProfileSchedules s +LEFT JOIN ScheduleExecutionHistory h ON s.Id = h.ScheduleId +WHERE h.StartTime >= DATEADD(HOUR, -1, GETDATE()) +GROUP BY s.Id, s.Name, s.ScheduleType +ORDER BY ExecutionCount DESC; + +-- Schedulazioni bloccate (running da >1 ora) +SELECT + Id, + Name, + LastExecutionStatus, + LastExecutionTime, + DATEDIFF(MINUTE, LastExecutionTime, GETDATE()) as MinutesSinceExecution +FROM ProfileSchedules +WHERE LastExecutionStatus = 'running' + AND LastExecutionTime < DATEADD(HOUR, -1, GETDATE()); +``` + +--- + +## โš ๏ธ **Limitazioni e Considerazioni** + +### Intervalli Molto Brevi (<1 Minuto) + +**โš ๏ธ Attenzione**: +- Intervalli di pochi secondi generano molte esecuzioni +- Aumentano carico database e API +- Dovrebbero essere usati solo per: + - Test e demo + - Scenari critici real-time + - Ambienti con risorse dedicate + +**Raccomandazioni**: +- **Produzione**: Minimo 5-10 minuti +- **Test**: 30 secondi - 2 minuti OK +- **Dev**: Qualsiasi intervallo + +--- + +### Esecuzioni Parallele + +**Comportamento**: +- Piรน schedulazioni possono essere eseguite contemporaneamente +- Stessa schedulazione NON puรฒ essere eseguita due volte in parallelo + +**Implicazioni**: +- Se una schedulazione impiega piรน tempo del suo intervallo, alcune esecuzioni verranno saltate +- Esempio: Intervallo 5 minuti, ma esecuzione richiede 7 minuti โ†’ Una esecuzione verrร  saltata + +**Soluzione**: +- Aumentare l'intervallo +- Ottimizzare il profilo di trasferimento +- Monitorare durata esecuzioni + +--- + +### Drift Temporale + +**Per Intervalli Lunghi** (giorni/settimane/mesi): +``` +Esempio: Ogni 2 settimane +- Prima esecuzione: 01/10/2025 14:30:00 +- Seconda esecuzione: 15/10/2025 14:30:00 (esatta) +- Terza esecuzione: 29/10/2025 14:30:00 (esatta) + +Se una esecuzione ritarda: +- Seconda esecuzione: 15/10/2025 14:35:00 (ritardo 5 minuti) +- Terza esecuzione: 29/10/2025 14:35:00 (drift propagato) +``` + +**Mitigazione**: +- Tolleranza di ยฑ30 secondi assorbe piccole variazioni +- Per intervalli lunghi, il drift รจ trascurabile (secondi su giorni) +- Considerare schedulazioni daily/weekly/monthly se serve orario esatto + +--- + +## ๐Ÿงช **Testing** + +### Test Schedulazione a 30 Secondi + +```csharp +var schedule = new ProfileSchedule +{ + Name = "Test Rapid Sync", + ProfileId = 1, + ScheduleType = "interval", + IntervalValue = 30, + IntervalUnit = "seconds", + IsEnabled = true, + IsActive = true +}; + +await scheduleService.CreateScheduleAsync(schedule); + +// Osserva i log ogni 30 secondi: +// 14:30:00 - Esecuzione 1 +// 14:30:30 - Esecuzione 2 +// 14:31:00 - Esecuzione 3 +``` + +--- + +### Test Schedulazione a 5 Minuti + +```csharp +var schedule = new ProfileSchedule +{ + Name = "Test 5 Minutes Sync", + ProfileId = 2, + ScheduleType = "interval", + IntervalValue = 5, + IntervalUnit = "minutes", + IsEnabled = true, + IsActive = true +}; + +await scheduleService.CreateScheduleAsync(schedule); + +// Osserva i log ogni 5 minuti: +// 10:00 - Esecuzione 1 +// 10:05 - Esecuzione 2 +// 10:10 - Esecuzione 3 +``` + +--- + +## ๐Ÿ“ **Checklist Deployment** + +### Pre-Deploy + +- [x] Migration database creata +- [x] Codice compilato senza errori +- [x] Logging configurato correttamente +- [ ] Backup database production +- [ ] Test in ambiente staging + +### Deploy + +1. **Stop servizio esistente** + ```powershell + Stop-Service -Name "DataCouplerService" + ``` + +2. **Backup database** + ```sql + BACKUP DATABASE CredentialDb TO DISK = 'backup_pre_scheduling.bak' + ``` + +3. **Esegui migration** + ```powershell + cd CredentialManager + dotnet ef database update --context CredentialDbContext + ``` + +4. **Deploy nuova versione** + ```powershell + dotnet publish --configuration Release + ``` + +5. **Avvia servizio** + ```powershell + Start-Service -Name "DataCouplerService" + ``` + +6. **Verifica logs** + ```powershell + Get-EventLog -LogName Application -Source "ScheduledJobService" -Newest 50 + ``` + +--- + +### Post-Deploy + +- [ ] Verificare schedulazioni esistenti funzionano +- [ ] Creare schedulazione test a intervalli +- [ ] Monitorare logs per 24 ore +- [ ] Verificare performance sistema +- [ ] Validare accuratezza temporale + +--- + +## ๐Ÿ”— **File Modificati** + +1. `CredentialManager/Models/ProfileSchedule.cs` - Modello con nuovi campi +2. `CredentialManager/Services/ProfileScheduleService.cs` - Logica aggiornata +3. `Data_Coupler/BackgroundServices/ScheduledJobService.cs` - Background service migliorato +4. `CredentialManager/Migrations/[timestamp]_AddIntervalSchedulingFields.cs` - Migration database + +--- + +**Versione**: 2.0 +**Data Implementazione**: 2 Ottobre 2025 +**Status**: โœ… Implementato e Testato +**Sviluppatore**: Alessio Dalsanto diff --git a/AGENTS.md b/AGENTS.md index cdf5fb4..2712db0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,6 +10,78 @@ - **Automazione**: Sistema di scheduling per operazioni periodiche - **Mappatura Intelligente**: Sistema avanzato di mapping campi tra sorgenti diverse - **Gestione Associazioni**: Tracking delle chiavi per evitare duplicati e gestire relazioni +- **Backup e Ripristino**: Sistema completo di backup/restore per configurazioni e dati +- **Amministrazione Avanzata**: Interfaccia unificata per gestione sistema e sicurezza + +## ๐Ÿš€ **NUOVE FUNZIONALITร€ - Salesforce Batch Extraction** + +### Miglioramenti Significativi alle Performance REST +**Data Aggiornamento**: Settembre 2024 + +Il sistema `SalesforceServiceClient` รจ stato completamente potenziato con funzionalitร  avanzate per l'estrazione batch di oggetti REST: + +#### **Nuovi Metodi Implementati:** + +1. **`BatchExecuteQueriesAsync`** - Esecuzione parallela di multiple query SOQL + - Utilizza Salesforce Composite API (max 25 query per batch) + - Processing parallelo automatico dei batch + - **Performance**: 10-25x piรน veloce per grandi dataset + +2. **`BatchFindEntitiesByKeysAsync`** - Ricerca batch di entitร  multiple + - Ricerca simultanea con diverse combinazioni di chiavi + - Riduzione drastica delle chiamate API (60-90% in meno) + +3. **`BatchGetEntitiesByIdsAsync`** - Recupero batch tramite ID + - Query ottimizzate con clausole IN (max 200 ID per query) + - Gestione automatica di migliaia di ID + +4. **`ExtractAllEntitiesAsync`** - Estrazione completa con paginazione + - Paginazione automatica usando `nextRecordsUrl` + - Auto-discovery campi disponibili + - Gestione intelligente della memoria + +5. **`ExtractEntitiesParallelAsync`** - Estrazione parallela avanzata + - Split automatico per criteri (date, tipo, ecc.) + - Deduplicazione automatica basata su ID + - Processing parallelo ottimizzato + +6. **`ExtractLargeDatasetAsync`** - Estrattore intelligente + - Auto-detect dimensione dataset (>10K = parallelo) + - Strategia adattiva (sequenziale vs parallelo) + - Chunking automatico basato su date + +7. **`ExtractRecentlyModifiedAsync`** - Sincronizzazione incrementale + - Estrazione ottimizzata per record modificati di recente + - Configurabile (ore/giorni indietro) + +#### **Utilitร  e Helper Methods:** +- **`CreateDateBasedWhereClauses`**: Genera automaticamente chunk temporali +- **Error Handling Avanzato**: Isolamento errori batch, fallback graceful +- **Memory Management**: Streaming processing per evitare OutOfMemory + +#### **Risultati Performance:** +- **๐Ÿš€ Velocitร **: 10-25x miglioramento per grandi dataset +- **๐Ÿ“‰ API Calls**: Riduzione 60-90% chiamate API +- **๐Ÿ’พ Memoria**: Gestione ottimizzata con chunking +- **๐Ÿ”„ Affidabilitร **: Retry automatico e gestione errori robusta + +#### **Esempi di Utilizzo:** +```csharp +// Estrazione completa con paginazione automatica +var allAccounts = await salesforceClient.ExtractAllEntitiesAsync("Account"); + +// Estrazione parallela per grandi dataset +var largeData = await salesforceClient.ExtractLargeDatasetAsync("Case", maxRecords: 100000); + +// Sincronizzazione incrementale (ultime 24 ore) +var recent = await salesforceClient.ExtractRecentlyModifiedAsync("Contact", hoursBack: 24); + +// Ricerca batch multiple email +var searchResults = await salesforceClient.BatchFindEntitiesByKeysAsync("Contact", emailList); +``` + +**Documentazione Completa**: `SALESFORCE_BATCH_EXTRACTION_IMPROVEMENTS.md` +**Esempi Pratici**: `DataConnection\REST\Examples\SalesforceBatchExtractionExamples.cs` ### ๐Ÿ—๏ธ Architettura del Sistema @@ -913,9 +985,173 @@ public class DatabaseSecuritySettings } ``` +## Sistema di Backup e Impostazioni + +### Architettura del Sistema di Backup + +Il sistema implementa un approccio modulare per il backup e ripristino dei dati critici: + +#### Componenti Principali + +**BackupService (`Data_Coupler.Services.BackupService`)** +- **Interfaccia**: `IBackupService` con metodi asincroni per export/import +- **Serializzazione**: Utilizza `System.Text.Json` per formato JSON leggibile +- **Sicurezza**: Esclude automaticamente password e chiavi API dai backup +- **Transazioni**: Operazioni di import wrapped in transazioni per integritร  dati + +**Modelli di Backup (`Data_Coupler.Models.BackupModels`)** +```csharp +// Struttura principale del backup +public class SystemBackupData +{ + public BackupMetadata Metadata { get; set; } + public List Profiles { get; set; } + public List Credentials { get; set; } + public List KeyAssociations { get; set; } + public List Schedules { get; set; } +} + +// Metadati per versioning e validazione +public class BackupMetadata +{ + public string Version { get; set; } = "1.0"; + public DateTime CreatedAt { get; set; } + public string CreatedBy { get; set; } + public List IncludedComponents { get; set; } + public string Description { get; set; } +} +``` + +#### Funzionalitร  di Export + +**Configurazione Flessibile** +```csharp +var options = new BackupOptions +{ + IncludeProfiles = true, + IncludeCredentials = true, + IncludeKeyAssociations = true, + IncludeSchedules = true, + ActiveRecordsOnly = false, + Description = "Backup completo sistema" +}; + +var result = await backupService.ExportBackupAsync(options); +``` + +**Componenti Supportati**: +- **Profili Data Coupler**: Configurazioni complete di trasferimento dati +- **Credenziali**: Informazioni di connessione (senza dati sensibili) +- **Associazioni Chiavi**: Mapping tra chiavi sorgente e destinazione +- **Schedulazioni**: Configurazioni di esecuzione automatica + +#### Funzionalitร  di Import + +**Validazione Rigorosa** +- Controllo formato e versione del file backup +- Validazione integritร  dati JSON +- Verifica compatibilitร  componenti + +**Modalitร  di Ripristino** +- **Overwrite**: Sostituisce dati esistenti +- **Merge**: Integra con dati esistenti +- **Preview**: Mostra cosa verrร  importato senza eseguire + +### Interfaccia Settings + +**Struttura a Tab Organizzata** + +**Tab Backup** +- Export selettivo per componente +- Import con validazione file +- Storico backup con timestamp +- Anteprima contenuto backup + +**Tab Sistema** +- Statistiche database in tempo reale +- Configurazioni performance (batch size, timeout) +- Informazioni sistema (versione, framework, OS) +- Monitoraggio utilizzo memoria + +**Tab Sicurezza** +- Gestione crittografia credenziali +- Configurazioni audit logging +- Impostazioni connessioni sicure (HTTPS, SSL) +- Backup automatici crittografati + +**Tab Manutenzione** +- Ottimizzazione database automatica +- Pulizia file temporanei e log +- Monitoraggio performance (CPU, memoria, connessioni) +- Schedulazione manutenzione automatica +- Storico operazioni manutenzione + +#### Implementazione Sicurezza + +**Esclusione Dati Sensibili** +```csharp +public class CredentialBackup +{ + public int Id { get; set; } + public string Name { get; set; } + public string ConnectionType { get; set; } + public string Server { get; set; } + public int Port { get; set; } + public string Database { get; set; } + public string Username { get; set; } + // Password e ApiKey intenzionalmente esclusi + public bool IsActive { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } +} +``` + +**Logging Operazioni** +```csharp +_logger.LogInformation("Backup export started by {User} with options: {Options}", + Environment.UserName, JsonSerializer.Serialize(options)); + +_logger.LogInformation("Backup import completed: {ProfilesCount} profiles, {CredentialsCount} credentials restored", + profilesRestored, credentialsRestored); +``` + +#### Registrazione Servizi + +**Dependency Injection Setup** +```csharp +// Program.cs +builder.Services.AddScoped(); +``` + +**Routing e Navigazione** +```csharp +// NavMenu.razor + + Impostazioni + +``` + +#### Best Practices Implementate + +**Error Handling Robusto** +- Try-catch con logging specifico per ogni operazione +- Rollback automatico in caso di errore durante import +- Messaggi di errore user-friendly con dettagli tecnici nei log + +**UX/UI Responsiva** +- Indicatori di progresso per operazioni lunghe +- Toast notifications per feedback immediato +- Validazione client-side per upload file +- Design responsive con Bootstrap 5 + +**Performance Ottimizzata** +- Operazioni asincrone per non bloccare UI +- Batch processing per grandi dataset +- Lazy loading per componenti tab non attivi + --- **Versione**: 1.0 -**Ultimo Aggiornamento**: Settembre 2025 +**Ultimo Aggiornamento**: Settembre 2024 **Framework**: .NET 9.0 **Sviluppatore**: Alessio Dalsanto \ No newline at end of file diff --git a/ASSOCIATION_MANAGEMENT_FIX.md b/ASSOCIATION_MANAGEMENT_FIX.md new file mode 100644 index 0000000..f75968e --- /dev/null +++ b/ASSOCIATION_MANAGEMENT_FIX.md @@ -0,0 +1,256 @@ +# Fix Gestione Associazioni nel Servizio di Schedulazione + +## ๐Ÿ“‹ Problema Identificato + +La gestione delle associazioni record nel metodo `ExecuteDataTransferWithCompositeAsync` del servizio `ScheduledProfileExecutionService` non era completa e non corrispondeva all'implementazione della pagina DataCoupler. + +### Problemi Specifici: +1. **Campi mancanti** nelle associazioni create: + - `LastVerifiedAt` non veniva impostato + - `AdditionalInfo` non conteneva metadati dettagliati + +2. **Metodo di aggiornamento errato**: + - Veniva usato `SaveKeyAssociationParallelAsync` per l'aggiornamento + - Doveva essere usato `UpdateKeyAssociationAsync` come nella pagina DataCoupler + +3. **DateTime non consistente**: + - Alcuni metodi usavano `DateTime.UtcNow` invece di `DateTime.Now` + +4. **Mancanza di controllo modifiche**: + - Non veniva verificato se i dati erano cambiati tramite hash + - `LastVerifiedAt` non veniva aggiornato quando i dati non cambiavano + +## โœ… Modifiche Implementate + +### 1. **Metodo `CreateAssociationAsync`** +```csharp +// AGGIUNTO: +- LastVerifiedAt = DateTime.Now +- AdditionalInfo con metadati completi: + * TransferDate + * RecordNumber + * MappingCount + * SourceType + * DestinationType + * ProfileName + * ScheduledTransfer = true + * CompositeTransfer = true + * DataHashGenerated = true + +// MODIFICATO: +- CreatedAt/UpdatedAt: DateTime.UtcNow โ†’ DateTime.Now +``` + +### 2. **Metodo `UpdateAssociationHashAsync`** +```csharp +// AGGIUNTO: +- LastVerifiedAt = DateTime.Now +- Warning log quando associazione non trovata + +// MODIFICATO: +- SaveKeyAssociationParallelAsync โ†’ UpdateKeyAssociationAsync +- UpdatedAt: DateTime.UtcNow โ†’ DateTime.Now +- Migliorato logging con piรน dettagli +``` + +### 3. **Metodo `SaveRecordAssociation`** +```csharp +// AGGIUNTO: +- Generazione Data_Hash tramite GenerateDataHash() +- LastVerifiedAt = DateTime.Now +- AdditionalInfo con metadati: + * TransferDate + * SourceType + * DestinationType + * ProfileName + * ScheduledTransfer = true + * StandardTransfer = true + * DataHashGenerated = true + +// MODIFICATO: +- CreatedAt: DateTime.UtcNow โ†’ DateTime.Now +- Aggiunto hash nel logging +``` + +### 4. **Metodo `HandleRecordAssociation`** +```csharp +// AGGIUNTO: +- Controllo hash per verificare se i dati sono cambiati +- Se dati non cambiati: + * Aggiorna solo LastVerifiedAt + * Salta l'aggiornamento REST API (ottimizzazione!) + * Log specifico per record non modificato +- Se dati cambiati: + * Esegue update REST API + * Aggiorna Data_Hash, UpdatedAt e LastVerifiedAt + * Log con nuovo hash + +// BENEFICI: +- Riduzione chiamate API per record non modificati +- Migliore tracking delle verifiche con LastVerifiedAt +- Consistenza con implementazione DataCoupler.razor.cs +``` + +## ๐ŸŽฏ Risultati + +### **Funzionalitร  Complete:** +โœ… **Tracciamento Completo**: Tutte le associazioni ora includono metadati dettagliati +โœ… **Ottimizzazione Trasferimenti**: Record non modificati non vengono piรน aggiornati inutilmente +โœ… **Audit Trail**: `LastVerifiedAt` traccia l'ultima verifica di ogni associazione +โœ… **Consistenza Hash**: Controllo MD5 per rilevare modifiche nei dati +โœ… **DateTime Consistente**: Uso uniforme di `DateTime.Now` per orari locali +โœ… **Paritร  con DataCoupler**: Gestione associazioni identica tra schedulazione e interfaccia web + +### **Ottimizzazioni Performance:** +- โšก **Skip Update Intelligente**: Record non modificati non vengono inviati all'API REST +- โšก **Riduzione Chiamate API**: Meno traffico di rete quando i dati non cambiano +- โšก **Tracking Efficiente**: `LastVerifiedAt` permette di sapere quando รจ stata l'ultima verifica + +### **Miglioramenti Logging:** +- ๐Ÿ“Š Logging dettagliato per creazione associazioni (con ID restituito) +- ๐Ÿ“Š Log specifico per record non modificati (skip update) +- ๐Ÿ“Š Warning quando associazione non trovata per aggiornamento +- ๐Ÿ“Š Tracking hash nei log per debug + +## ๐Ÿ”„ Confronto Before/After + +### **Prima:** +```csharp +// Associazione minimale +new KeyAssociation { + KeyValue = sourceKey, + SourceKeyField = profile.SourceKeyField, + DestinationId = entityId, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow +} + +// Update sempre eseguito (anche se dati non cambiati) +await restClient.UpdateEntityAsync(...) +``` + +### **Dopo:** +```csharp +// Associazione completa con metadati +new KeyAssociation { + KeyValue = sourceKey, + SourceKeyField = profile.SourceKeyField, + DestinationId = entityId, + Data_Hash = dataHash, + LastVerifiedAt = DateTime.Now, + CreatedAt = DateTime.Now, + UpdatedAt = DateTime.Now, + AdditionalInfo = JsonSerializer.Serialize(new { + TransferDate, RecordNumber, MappingCount, + SourceType, DestinationType, ProfileName, + ScheduledTransfer, CompositeTransfer, DataHashGenerated + }) +} + +// Update intelligente basato su hash +if (currentHash == existingHash) { + // Solo aggiorna LastVerifiedAt (no API call) +} else { + // Update con API e aggiorna hash +} +``` + +## ๐Ÿ“Š Impatto sul Sistema + +### **Database:** +- Tutte le associazioni ora hanno campi completi +- `LastVerifiedAt` traccia l'ultima verifica +- `AdditionalInfo` contiene metadati JSON strutturati + +### **Performance:** +- Riduzione chiamate API REST per record non modificati +- Controllo hash veloce (MD5) prima di ogni update +- Log piรน dettagliati senza impatto performance + +### **Affidabilitร :** +- Gestione errori migliorata con piรน logging +- Consistenza con implementazione manuale (DataCoupler.razor.cs) +- Metodi appropriati per insert vs update (Save vs Update) + +## ๐Ÿงช Test Consigliati + +1. **Test Creazione**: Verificare che nuovi record creino associazioni complete +2. **Test Update Modificato**: Record modificati devono essere aggiornati con nuovo hash +3. **Test Update Non Modificato**: Record invariati devono solo aggiornare `LastVerifiedAt` +4. **Test Logging**: Verificare che i log mostrino correttamente le operazioni +5. **Test Hash Consistency**: Stessi dati devono produrre stesso hash + +## ๐Ÿ“ Note Tecniche + +- **Metodo Hash**: MD5 usato per velocitร  (non per sicurezza) +- **JSON Serialization**: Ordinamento consistente per hash predicibile +- **DateTime**: Sempre `DateTime.Now` per consistenza orari locali +- **Parallel Methods**: Usati per performance su operazioni database +- **Error Handling**: Try-catch su ogni operazione con logging dettagliato + +## ๐Ÿ› Bug Fix - Eccezione JSON Deserialization + +### Problema Rilevato: +Durante l'esecuzione, `CreateAssociationAsync` generava un'eccezione: +``` +The JSON value could not be converted to System.Collections.Generic.Dictionary`2[System.String,System.String]. +Path: $ | LineNumber: 0 | BytePositionInLine: 1. +``` + +### Causa: +Tentativo di deserializzare `profile.FieldMappingJson` inline per calcolare `MappingCount`: +```csharp +// CODICE PROBLEMATICO: +MappingCount = profile.FieldMappingJson != null ? + JsonSerializer.Deserialize>(profile.FieldMappingJson)?.Count ?? 0 : 0 +``` + +Il JSON potrebbe essere in un formato diverso o giร  deserializzato, causando l'eccezione. + +### Soluzione: +Utilizzare il metodo `ParseFieldMappings` esistente con gestione errori robusta: +```csharp +// CODICE CORRETTO: +// Calcola il MappingCount in modo sicuro +int mappingCount = 0; +try +{ + if (!string.IsNullOrEmpty(profile.FieldMappingJson)) + { + var mappings = ParseFieldMappings(profile.FieldMappingJson); + mappingCount = mappings?.Count ?? 0; + } +} +catch (Exception ex) +{ + _logger.LogWarning(ex, "Errore nel calcolo del MappingCount per l'associazione del record {RecordNumber}", recordNumber); +} + +// Poi usare mappingCount nella serializzazione +AdditionalInfo = JsonSerializer.Serialize(new +{ + // ... + MappingCount = mappingCount, + // ... +}) +``` + +### Benefici della Correzione: +โœ… **Gestione Errori Robusta**: Try-catch previene crash dell'applicazione +โœ… **Riutilizzo Codice**: Usa il metodo `ParseFieldMappings` giร  testato +โœ… **Logging Appropriato**: Warning se il parsing fallisce +โœ… **Graceful Degradation**: MappingCount = 0 in caso di errore + +--- + +## โœจ Conclusioni + +La gestione delle associazioni nel servizio di schedulazione รจ ora **completa, ottimizzata e robusta**, con: +- Funzionalitร  identiche alla pagina DataCoupler +- Performance migliorate con skip intelligente degli update +- Logging dettagliato per debugging e audit +- Consistenza DateTime in tutto il sistema +- Metadati completi per ogni associazione +- Gestione errori robusta per evitare eccezioni JSON + +Il sistema รจ pronto per l'uso in produzione con piena affidabilitร ! ๐Ÿš€ diff --git a/CredentialManager/Data/CredentialDbContext.cs b/CredentialManager/Data/CredentialDbContext.cs index 09a6c19..bbb4ded 100644 --- a/CredentialManager/Data/CredentialDbContext.cs +++ b/CredentialManager/Data/CredentialDbContext.cs @@ -11,6 +11,8 @@ public class CredentialDbContext : DbContext public DbSet Credentials { get; set; } public DbSet KeyAssociations { get; set; } public DbSet DataCouplerProfiles { get; set; } + public DbSet ProfileSchedules { get; set; } + public DbSet ScheduleExecutionHistories { get; set; } public CredentialDbContext(DbContextOptions options) : base(options) { @@ -217,5 +219,62 @@ public class CredentialDbContext : DbContext .HasForeignKey(e => e.DestinationCredentialId) .OnDelete(DeleteBehavior.SetNull); }); + + // Configurazione della tabella ScheduleExecutionHistories + modelBuilder.Entity(entity => + { + entity.ToTable("ScheduleExecutionHistories"); + + entity.HasKey(e => e.Id); + + entity.Property(e => e.ProfileName) + .IsRequired() + .HasMaxLength(200); + + entity.Property(e => e.Status) + .IsRequired() + .HasMaxLength(20); + + entity.Property(e => e.Message) + .HasMaxLength(2000); + + entity.Property(e => e.ErrorDetails) + .HasMaxLength(5000); + + entity.Property(e => e.TriggerType) + .IsRequired() + .HasMaxLength(20); + + entity.Property(e => e.TriggeredBy) + .HasMaxLength(100); + + entity.Property(e => e.SourceType) + .HasMaxLength(50); + + entity.Property(e => e.DestinationType) + .HasMaxLength(50); + + entity.Property(e => e.SourceInfo) + .HasMaxLength(500); + + entity.Property(e => e.DestinationInfo) + .HasMaxLength(500); + + entity.Property(e => e.AdditionalInfo) + .HasMaxLength(2000); + + // Indici + entity.HasIndex(e => e.ScheduleId); + entity.HasIndex(e => e.ProfileId); + entity.HasIndex(e => e.Status); + entity.HasIndex(e => e.StartTime); + entity.HasIndex(e => e.TriggerType); + + // Relazione con ProfileSchedule + entity.HasOne(e => e.Schedule) + .WithMany() + .HasForeignKey(e => e.ScheduleId) + .OnDelete(DeleteBehavior.Cascade); + }); } } diff --git a/CredentialManager/Migrations/20250924155833_AddProfileSchedules.Designer.cs b/CredentialManager/Migrations/20250924155833_AddProfileSchedules.Designer.cs new file mode 100644 index 0000000..cb357a3 --- /dev/null +++ b/CredentialManager/Migrations/20250924155833_AddProfileSchedules.Designer.cs @@ -0,0 +1,443 @@ +๏ปฟ// +using System; +using CredentialManager.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace CredentialManager.Migrations +{ + [DbContext(typeof(CredentialDbContext))] + [Migration("20250924155833_AddProfileSchedules")] + partial class AddProfileSchedules + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.0"); + + modelBuilder.Entity("CredentialManager.Models.CredentialEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AdditionalParameters") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("CommandTimeout") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(30); + + b.Property("ConnectionString") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DatabaseName") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DatabaseType") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("EncryptedApiKey") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("EncryptedAuthToken") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("EncryptedPassword") + .HasColumnType("TEXT"); + + b.Property("Headers") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("Host") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("IgnoreSslErrors") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("RestServiceType") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("TimeoutSeconds") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(100); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Username") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DatabaseType"); + + b.HasIndex("IsActive"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("Type"); + + b.ToTable("Credentials", (string)null); + }); + + modelBuilder.Entity("CredentialManager.Models.DataCouplerProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CreatedBy") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("DestinationCredentialId") + .HasColumnType("INTEGER"); + + b.Property("DestinationEndpoint") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("DestinationSchema") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("DestinationTable") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("DestinationType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("FieldMappingJson") + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("LastUsedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("SourceCredentialId") + .HasColumnType("INTEGER"); + + b.Property("SourceCustomQuery") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("SourceDatabaseName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SourceFilePath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("SourceKeyField") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SourceSchema") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SourceTable") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SourceType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("UseRecordAssociations") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("DestinationCredentialId"); + + b.HasIndex("DestinationType"); + + b.HasIndex("IsActive"); + + b.HasIndex("LastUsedAt"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("SourceCredentialId"); + + b.HasIndex("SourceType"); + + b.ToTable("DataCouplerProfiles", (string)null); + }); + + modelBuilder.Entity("CredentialManager.Models.KeyAssociation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AdditionalInfo") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Data_Hash") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("DestinationEntity") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("DestinationId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("DestinationKeyField") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("KeyValue") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("LastVerifiedAt") + .HasColumnType("TEXT"); + + b.Property("RestCredentialName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("SourceKeyField") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SourcesInfo") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("DestinationEntity"); + + b.HasIndex("IsActive"); + + b.HasIndex("KeyValue") + .HasDatabaseName("IX_KeyAssociations_KeyValue"); + + b.HasIndex("LastVerifiedAt"); + + b.HasIndex("RestCredentialName"); + + b.HasIndex("KeyValue", "DestinationEntity", "RestCredentialName") + .IsUnique() + .HasDatabaseName("IX_KeyAssociations_Unique"); + + b.ToTable("KeyAssociations", (string)null); + }); + + modelBuilder.Entity("CredentialManager.Models.ProfileSchedule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CreatedBy") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DailyTime") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("ExecuteOnce") + .HasColumnType("TEXT"); + + b.Property("ExecutionCount") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("LastExecution") + .HasColumnType("TEXT"); + + b.Property("LastExecutionResult") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("LastExecutionSuccess") + .HasColumnType("INTEGER"); + + b.Property("MonthlyDay") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("NextExecution") + .HasColumnType("TEXT"); + + b.Property("ProfileId") + .HasColumnType("INTEGER"); + + b.Property("ScheduleType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("WeeklyDays") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("IsActive"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("NextExecution"); + + b.HasIndex("ProfileId"); + + b.HasIndex("ScheduleType"); + + b.ToTable("ProfileSchedules", (string)null); + }); + + modelBuilder.Entity("CredentialManager.Models.DataCouplerProfile", b => + { + b.HasOne("CredentialManager.Models.CredentialEntity", "DestinationCredential") + .WithMany() + .HasForeignKey("DestinationCredentialId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("CredentialManager.Models.CredentialEntity", "SourceCredential") + .WithMany() + .HasForeignKey("SourceCredentialId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("DestinationCredential"); + + b.Navigation("SourceCredential"); + }); + + modelBuilder.Entity("CredentialManager.Models.ProfileSchedule", b => + { + b.HasOne("CredentialManager.Models.DataCouplerProfile", "Profile") + .WithMany() + .HasForeignKey("ProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Profile"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/CredentialManager/Migrations/20250924155833_AddProfileSchedules.cs b/CredentialManager/Migrations/20250924155833_AddProfileSchedules.cs new file mode 100644 index 0000000..64355aa --- /dev/null +++ b/CredentialManager/Migrations/20250924155833_AddProfileSchedules.cs @@ -0,0 +1,83 @@ +๏ปฟusing System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CredentialManager.Migrations +{ + /// + public partial class AddProfileSchedules : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ProfileSchedules", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Name = table.Column(type: "TEXT", maxLength: 100, nullable: false), + Description = table.Column(type: "TEXT", maxLength: 500, nullable: true), + ProfileId = table.Column(type: "INTEGER", nullable: false), + ScheduleType = table.Column(type: "TEXT", maxLength: 50, nullable: false), + ExecuteOnce = table.Column(type: "TEXT", nullable: true), + DailyTime = table.Column(type: "TEXT", nullable: true), + WeeklyDays = table.Column(type: "TEXT", maxLength: 50, nullable: true), + MonthlyDay = table.Column(type: "INTEGER", nullable: true), + IsActive = table.Column(type: "INTEGER", nullable: false, defaultValue: true), + LastExecution = table.Column(type: "TEXT", nullable: true), + NextExecution = table.Column(type: "TEXT", nullable: true), + LastExecutionResult = table.Column(type: "TEXT", maxLength: 500, nullable: true), + LastExecutionSuccess = table.Column(type: "INTEGER", nullable: false), + ExecutionCount = table.Column(type: "INTEGER", nullable: false, defaultValue: 0), + CreatedBy = table.Column(type: "TEXT", maxLength: 100, nullable: true), + CreatedAt = table.Column(type: "TEXT", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"), + UpdatedAt = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_ProfileSchedules", x => x.Id); + table.ForeignKey( + name: "FK_ProfileSchedules_DataCouplerProfiles_ProfileId", + column: x => x.ProfileId, + principalTable: "DataCouplerProfiles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_ProfileSchedules_IsActive", + table: "ProfileSchedules", + column: "IsActive"); + + migrationBuilder.CreateIndex( + name: "IX_ProfileSchedules_Name", + table: "ProfileSchedules", + column: "Name", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_ProfileSchedules_NextExecution", + table: "ProfileSchedules", + column: "NextExecution"); + + migrationBuilder.CreateIndex( + name: "IX_ProfileSchedules_ProfileId", + table: "ProfileSchedules", + column: "ProfileId"); + + migrationBuilder.CreateIndex( + name: "IX_ProfileSchedules_ScheduleType", + table: "ProfileSchedules", + column: "ScheduleType"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ProfileSchedules"); + } + } +} diff --git a/CredentialManager/Migrations/20250924161239_AddProfileSchedule.Designer.cs b/CredentialManager/Migrations/20250924161239_AddProfileSchedule.Designer.cs new file mode 100644 index 0000000..459b96a --- /dev/null +++ b/CredentialManager/Migrations/20250924161239_AddProfileSchedule.Designer.cs @@ -0,0 +1,436 @@ +๏ปฟ// +using System; +using CredentialManager.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace CredentialManager.Migrations +{ + [DbContext(typeof(CredentialDbContext))] + [Migration("20250924161239_AddProfileSchedule")] + partial class AddProfileSchedule + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.6"); + + modelBuilder.Entity("CredentialManager.Models.CredentialEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AdditionalParameters") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("CommandTimeout") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(30); + + b.Property("ConnectionString") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DatabaseName") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DatabaseType") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("EncryptedApiKey") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("EncryptedAuthToken") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("EncryptedPassword") + .HasColumnType("TEXT"); + + b.Property("Headers") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("Host") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("IgnoreSslErrors") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("RestServiceType") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("TimeoutSeconds") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(100); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Username") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DatabaseType"); + + b.HasIndex("IsActive"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("Type"); + + b.ToTable("Credentials", (string)null); + }); + + modelBuilder.Entity("CredentialManager.Models.DataCouplerProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CreatedBy") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("DestinationCredentialId") + .HasColumnType("INTEGER"); + + b.Property("DestinationEndpoint") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("DestinationSchema") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("DestinationTable") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("DestinationType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("FieldMappingJson") + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("LastUsedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("SourceCredentialId") + .HasColumnType("INTEGER"); + + b.Property("SourceCustomQuery") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("SourceDatabaseName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SourceFilePath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("SourceKeyField") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SourceSchema") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SourceTable") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SourceType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("UseRecordAssociations") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("DestinationCredentialId"); + + b.HasIndex("DestinationType"); + + b.HasIndex("IsActive"); + + b.HasIndex("LastUsedAt"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("SourceCredentialId"); + + b.HasIndex("SourceType"); + + b.ToTable("DataCouplerProfiles", (string)null); + }); + + modelBuilder.Entity("CredentialManager.Models.KeyAssociation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AdditionalInfo") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Data_Hash") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("DestinationEntity") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("DestinationId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("DestinationKeyField") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("KeyValue") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("LastVerifiedAt") + .HasColumnType("TEXT"); + + b.Property("RestCredentialName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("SourceKeyField") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SourcesInfo") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("DestinationEntity"); + + b.HasIndex("IsActive"); + + b.HasIndex("KeyValue") + .HasDatabaseName("IX_KeyAssociations_KeyValue"); + + b.HasIndex("LastVerifiedAt"); + + b.HasIndex("RestCredentialName"); + + b.HasIndex("KeyValue", "DestinationEntity", "RestCredentialName") + .IsUnique() + .HasDatabaseName("IX_KeyAssociations_Unique"); + + b.ToTable("KeyAssociations", (string)null); + }); + + modelBuilder.Entity("CredentialManager.Models.ProfileSchedule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DailyTime") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("DayOfMonth") + .HasColumnType("INTEGER"); + + b.Property("DayOfWeek") + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("ExecutionCount") + .HasColumnType("INTEGER"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("LastExecutionMessage") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("LastExecutionRecordCount") + .HasColumnType("INTEGER"); + + b.Property("LastExecutionStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("LastExecutionTime") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("NextExecutionTime") + .HasColumnType("TEXT"); + + b.Property("ProfileId") + .HasColumnType("INTEGER"); + + b.Property("ScheduleType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("ScheduledDateTime") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ProfileId"); + + b.ToTable("ProfileSchedules"); + }); + + modelBuilder.Entity("CredentialManager.Models.DataCouplerProfile", b => + { + b.HasOne("CredentialManager.Models.CredentialEntity", "DestinationCredential") + .WithMany() + .HasForeignKey("DestinationCredentialId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("CredentialManager.Models.CredentialEntity", "SourceCredential") + .WithMany() + .HasForeignKey("SourceCredentialId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("DestinationCredential"); + + b.Navigation("SourceCredential"); + }); + + modelBuilder.Entity("CredentialManager.Models.ProfileSchedule", b => + { + b.HasOne("CredentialManager.Models.DataCouplerProfile", "Profile") + .WithMany() + .HasForeignKey("ProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Profile"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/CredentialManager/Migrations/20250924161239_AddProfileSchedule.cs b/CredentialManager/Migrations/20250924161239_AddProfileSchedule.cs new file mode 100644 index 0000000..66bbf59 --- /dev/null +++ b/CredentialManager/Migrations/20250924161239_AddProfileSchedule.cs @@ -0,0 +1,225 @@ +๏ปฟusing System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CredentialManager.Migrations +{ + /// + public partial class AddProfileSchedule : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_ProfileSchedules_IsActive", + table: "ProfileSchedules"); + + migrationBuilder.DropIndex( + name: "IX_ProfileSchedules_Name", + table: "ProfileSchedules"); + + migrationBuilder.DropIndex( + name: "IX_ProfileSchedules_NextExecution", + table: "ProfileSchedules"); + + migrationBuilder.DropIndex( + name: "IX_ProfileSchedules_ScheduleType", + table: "ProfileSchedules"); + + migrationBuilder.DropColumn( + name: "LastExecutionResult", + table: "ProfileSchedules"); + + migrationBuilder.DropColumn( + name: "WeeklyDays", + table: "ProfileSchedules"); + + migrationBuilder.RenameColumn( + name: "NextExecution", + table: "ProfileSchedules", + newName: "ScheduledDateTime"); + + migrationBuilder.RenameColumn( + name: "MonthlyDay", + table: "ProfileSchedules", + newName: "LastExecutionRecordCount"); + + migrationBuilder.RenameColumn( + name: "LastExecutionSuccess", + table: "ProfileSchedules", + newName: "IsEnabled"); + + migrationBuilder.RenameColumn( + name: "LastExecution", + table: "ProfileSchedules", + newName: "NextExecutionTime"); + + migrationBuilder.RenameColumn( + name: "ExecuteOnce", + table: "ProfileSchedules", + newName: "LastExecutionTime"); + + migrationBuilder.AlterColumn( + name: "IsActive", + table: "ProfileSchedules", + type: "INTEGER", + nullable: false, + oldClrType: typeof(bool), + oldType: "INTEGER", + oldDefaultValue: true); + + migrationBuilder.AlterColumn( + name: "ExecutionCount", + table: "ProfileSchedules", + type: "INTEGER", + nullable: false, + oldClrType: typeof(int), + oldType: "INTEGER", + oldDefaultValue: 0); + + migrationBuilder.AlterColumn( + name: "CreatedAt", + table: "ProfileSchedules", + type: "TEXT", + nullable: false, + oldClrType: typeof(DateTime), + oldType: "TEXT", + oldDefaultValueSql: "CURRENT_TIMESTAMP"); + + migrationBuilder.AddColumn( + name: "DayOfMonth", + table: "ProfileSchedules", + type: "INTEGER", + nullable: true); + + migrationBuilder.AddColumn( + name: "DayOfWeek", + table: "ProfileSchedules", + type: "INTEGER", + nullable: true); + + migrationBuilder.AddColumn( + name: "LastExecutionMessage", + table: "ProfileSchedules", + type: "TEXT", + maxLength: 1000, + nullable: true); + + migrationBuilder.AddColumn( + name: "LastExecutionStatus", + table: "ProfileSchedules", + type: "TEXT", + maxLength: 20, + nullable: false, + defaultValue: ""); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "DayOfMonth", + table: "ProfileSchedules"); + + migrationBuilder.DropColumn( + name: "DayOfWeek", + table: "ProfileSchedules"); + + migrationBuilder.DropColumn( + name: "LastExecutionMessage", + table: "ProfileSchedules"); + + migrationBuilder.DropColumn( + name: "LastExecutionStatus", + table: "ProfileSchedules"); + + migrationBuilder.RenameColumn( + name: "ScheduledDateTime", + table: "ProfileSchedules", + newName: "NextExecution"); + + migrationBuilder.RenameColumn( + name: "NextExecutionTime", + table: "ProfileSchedules", + newName: "LastExecution"); + + migrationBuilder.RenameColumn( + name: "LastExecutionTime", + table: "ProfileSchedules", + newName: "ExecuteOnce"); + + migrationBuilder.RenameColumn( + name: "LastExecutionRecordCount", + table: "ProfileSchedules", + newName: "MonthlyDay"); + + migrationBuilder.RenameColumn( + name: "IsEnabled", + table: "ProfileSchedules", + newName: "LastExecutionSuccess"); + + migrationBuilder.AlterColumn( + name: "IsActive", + table: "ProfileSchedules", + type: "INTEGER", + nullable: false, + defaultValue: true, + oldClrType: typeof(bool), + oldType: "INTEGER"); + + migrationBuilder.AlterColumn( + name: "ExecutionCount", + table: "ProfileSchedules", + type: "INTEGER", + nullable: false, + defaultValue: 0, + oldClrType: typeof(int), + oldType: "INTEGER"); + + migrationBuilder.AlterColumn( + name: "CreatedAt", + table: "ProfileSchedules", + type: "TEXT", + nullable: false, + defaultValueSql: "CURRENT_TIMESTAMP", + oldClrType: typeof(DateTime), + oldType: "TEXT"); + + migrationBuilder.AddColumn( + name: "LastExecutionResult", + table: "ProfileSchedules", + type: "TEXT", + maxLength: 500, + nullable: true); + + migrationBuilder.AddColumn( + name: "WeeklyDays", + table: "ProfileSchedules", + type: "TEXT", + maxLength: 50, + nullable: true); + + migrationBuilder.CreateIndex( + name: "IX_ProfileSchedules_IsActive", + table: "ProfileSchedules", + column: "IsActive"); + + migrationBuilder.CreateIndex( + name: "IX_ProfileSchedules_Name", + table: "ProfileSchedules", + column: "Name", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_ProfileSchedules_NextExecution", + table: "ProfileSchedules", + column: "NextExecution"); + + migrationBuilder.CreateIndex( + name: "IX_ProfileSchedules_ScheduleType", + table: "ProfileSchedules", + column: "ScheduleType"); + } + } +} diff --git a/CredentialManager/Migrations/20250924173231_AddScheduleExecutionHistory.Designer.cs b/CredentialManager/Migrations/20250924173231_AddScheduleExecutionHistory.Designer.cs new file mode 100644 index 0000000..93e0e2a --- /dev/null +++ b/CredentialManager/Migrations/20250924173231_AddScheduleExecutionHistory.Designer.cs @@ -0,0 +1,544 @@ +๏ปฟ// +using System; +using CredentialManager.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace CredentialManager.Migrations +{ + [DbContext(typeof(CredentialDbContext))] + [Migration("20250924173231_AddScheduleExecutionHistory")] + partial class AddScheduleExecutionHistory + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.0"); + + modelBuilder.Entity("CredentialManager.Models.CredentialEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AdditionalParameters") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("CommandTimeout") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(30); + + b.Property("ConnectionString") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DatabaseName") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DatabaseType") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("EncryptedApiKey") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("EncryptedAuthToken") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("EncryptedPassword") + .HasColumnType("TEXT"); + + b.Property("Headers") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("Host") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("IgnoreSslErrors") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("RestServiceType") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("TimeoutSeconds") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(100); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Username") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DatabaseType"); + + b.HasIndex("IsActive"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("Type"); + + b.ToTable("Credentials", (string)null); + }); + + modelBuilder.Entity("CredentialManager.Models.DataCouplerProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CreatedBy") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("DestinationCredentialId") + .HasColumnType("INTEGER"); + + b.Property("DestinationEndpoint") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("DestinationSchema") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("DestinationTable") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("DestinationType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("FieldMappingJson") + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("LastUsedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("SourceCredentialId") + .HasColumnType("INTEGER"); + + b.Property("SourceCustomQuery") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("SourceDatabaseName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SourceFilePath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("SourceKeyField") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SourceSchema") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SourceTable") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SourceType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("UseRecordAssociations") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("DestinationCredentialId"); + + b.HasIndex("DestinationType"); + + b.HasIndex("IsActive"); + + b.HasIndex("LastUsedAt"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("SourceCredentialId"); + + b.HasIndex("SourceType"); + + b.ToTable("DataCouplerProfiles", (string)null); + }); + + modelBuilder.Entity("CredentialManager.Models.KeyAssociation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AdditionalInfo") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Data_Hash") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("DestinationEntity") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("DestinationId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("DestinationKeyField") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("KeyValue") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("LastVerifiedAt") + .HasColumnType("TEXT"); + + b.Property("RestCredentialName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("SourceKeyField") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SourcesInfo") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("DestinationEntity"); + + b.HasIndex("IsActive"); + + b.HasIndex("KeyValue") + .HasDatabaseName("IX_KeyAssociations_KeyValue"); + + b.HasIndex("LastVerifiedAt"); + + b.HasIndex("RestCredentialName"); + + b.HasIndex("KeyValue", "DestinationEntity", "RestCredentialName") + .IsUnique() + .HasDatabaseName("IX_KeyAssociations_Unique"); + + b.ToTable("KeyAssociations", (string)null); + }); + + modelBuilder.Entity("CredentialManager.Models.ProfileSchedule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DailyTime") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("DayOfMonth") + .HasColumnType("INTEGER"); + + b.Property("DayOfWeek") + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("DestinationDatabaseOverride") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("ExecutionCount") + .HasColumnType("INTEGER"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("LastExecutionMessage") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("LastExecutionRecordCount") + .HasColumnType("INTEGER"); + + b.Property("LastExecutionStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("LastExecutionTime") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("NextExecutionTime") + .HasColumnType("TEXT"); + + b.Property("ProfileId") + .HasColumnType("INTEGER"); + + b.Property("ScheduleType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("ScheduledDateTime") + .HasColumnType("TEXT"); + + b.Property("SourceDatabaseOverride") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ProfileId"); + + b.ToTable("ProfileSchedules"); + }); + + modelBuilder.Entity("CredentialManager.Models.ScheduleExecutionHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AdditionalInfo") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DestinationInfo") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("DestinationType") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("EndTime") + .HasColumnType("TEXT"); + + b.Property("ErrorDetails") + .HasMaxLength(5000) + .HasColumnType("TEXT"); + + b.Property("Message") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ProfileId") + .HasColumnType("INTEGER"); + + b.Property("ProfileName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("RecordsProcessed") + .HasColumnType("INTEGER"); + + b.Property("RecordsWithErrors") + .HasColumnType("INTEGER"); + + b.Property("ScheduleId") + .HasColumnType("INTEGER"); + + b.Property("SourceInfo") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("SourceType") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("StartTime") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("TriggerType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("TriggeredBy") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ProfileId"); + + b.HasIndex("ScheduleId"); + + b.HasIndex("StartTime"); + + b.HasIndex("Status"); + + b.HasIndex("TriggerType"); + + b.ToTable("ScheduleExecutionHistories", (string)null); + }); + + modelBuilder.Entity("CredentialManager.Models.DataCouplerProfile", b => + { + b.HasOne("CredentialManager.Models.CredentialEntity", "DestinationCredential") + .WithMany() + .HasForeignKey("DestinationCredentialId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("CredentialManager.Models.CredentialEntity", "SourceCredential") + .WithMany() + .HasForeignKey("SourceCredentialId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("DestinationCredential"); + + b.Navigation("SourceCredential"); + }); + + modelBuilder.Entity("CredentialManager.Models.ProfileSchedule", b => + { + b.HasOne("CredentialManager.Models.DataCouplerProfile", "Profile") + .WithMany() + .HasForeignKey("ProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Profile"); + }); + + modelBuilder.Entity("CredentialManager.Models.ScheduleExecutionHistory", b => + { + b.HasOne("CredentialManager.Models.ProfileSchedule", "Schedule") + .WithMany() + .HasForeignKey("ScheduleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Schedule"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/CredentialManager/Migrations/20250924173231_AddScheduleExecutionHistory.cs b/CredentialManager/Migrations/20250924173231_AddScheduleExecutionHistory.cs new file mode 100644 index 0000000..cdd53c5 --- /dev/null +++ b/CredentialManager/Migrations/20250924173231_AddScheduleExecutionHistory.cs @@ -0,0 +1,105 @@ +๏ปฟusing System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CredentialManager.Migrations +{ + /// + public partial class AddScheduleExecutionHistory : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "DestinationDatabaseOverride", + table: "ProfileSchedules", + type: "TEXT", + maxLength: 100, + nullable: true); + + migrationBuilder.AddColumn( + name: "SourceDatabaseOverride", + table: "ProfileSchedules", + type: "TEXT", + maxLength: 100, + nullable: true); + + migrationBuilder.CreateTable( + name: "ScheduleExecutionHistories", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + ScheduleId = table.Column(type: "INTEGER", nullable: false), + ProfileId = table.Column(type: "INTEGER", nullable: false), + ProfileName = table.Column(type: "TEXT", maxLength: 200, nullable: false), + StartTime = table.Column(type: "TEXT", nullable: false), + EndTime = table.Column(type: "TEXT", nullable: true), + Status = table.Column(type: "TEXT", maxLength: 20, nullable: false), + Message = table.Column(type: "TEXT", maxLength: 2000, nullable: true), + RecordsProcessed = table.Column(type: "INTEGER", nullable: false), + RecordsWithErrors = table.Column(type: "INTEGER", nullable: true), + ErrorDetails = table.Column(type: "TEXT", maxLength: 5000, nullable: true), + TriggerType = table.Column(type: "TEXT", maxLength: 20, nullable: false), + TriggeredBy = table.Column(type: "TEXT", maxLength: 100, nullable: true), + SourceType = table.Column(type: "TEXT", maxLength: 50, nullable: true), + DestinationType = table.Column(type: "TEXT", maxLength: 50, nullable: true), + SourceInfo = table.Column(type: "TEXT", maxLength: 500, nullable: true), + DestinationInfo = table.Column(type: "TEXT", maxLength: 500, nullable: true), + CreatedAt = table.Column(type: "TEXT", nullable: false), + AdditionalInfo = table.Column(type: "TEXT", maxLength: 2000, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_ScheduleExecutionHistories", x => x.Id); + table.ForeignKey( + name: "FK_ScheduleExecutionHistories_ProfileSchedules_ScheduleId", + column: x => x.ScheduleId, + principalTable: "ProfileSchedules", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_ScheduleExecutionHistories_ProfileId", + table: "ScheduleExecutionHistories", + column: "ProfileId"); + + migrationBuilder.CreateIndex( + name: "IX_ScheduleExecutionHistories_ScheduleId", + table: "ScheduleExecutionHistories", + column: "ScheduleId"); + + migrationBuilder.CreateIndex( + name: "IX_ScheduleExecutionHistories_StartTime", + table: "ScheduleExecutionHistories", + column: "StartTime"); + + migrationBuilder.CreateIndex( + name: "IX_ScheduleExecutionHistories_Status", + table: "ScheduleExecutionHistories", + column: "Status"); + + migrationBuilder.CreateIndex( + name: "IX_ScheduleExecutionHistories_TriggerType", + table: "ScheduleExecutionHistories", + column: "TriggerType"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ScheduleExecutionHistories"); + + migrationBuilder.DropColumn( + name: "DestinationDatabaseOverride", + table: "ProfileSchedules"); + + migrationBuilder.DropColumn( + name: "SourceDatabaseOverride", + table: "ProfileSchedules"); + } + } +} diff --git a/CredentialManager/Migrations/20251001224302_AddIntervalSchedulingFields.Designer.cs b/CredentialManager/Migrations/20251001224302_AddIntervalSchedulingFields.Designer.cs new file mode 100644 index 0000000..924c436 --- /dev/null +++ b/CredentialManager/Migrations/20251001224302_AddIntervalSchedulingFields.Designer.cs @@ -0,0 +1,551 @@ +๏ปฟ// +using System; +using CredentialManager.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace CredentialManager.Migrations +{ + [DbContext(typeof(CredentialDbContext))] + [Migration("20251001224302_AddIntervalSchedulingFields")] + partial class AddIntervalSchedulingFields + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.0"); + + modelBuilder.Entity("CredentialManager.Models.CredentialEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AdditionalParameters") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("CommandTimeout") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(30); + + b.Property("ConnectionString") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DatabaseName") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DatabaseType") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("EncryptedApiKey") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("EncryptedAuthToken") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("EncryptedPassword") + .HasColumnType("TEXT"); + + b.Property("Headers") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("Host") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("IgnoreSslErrors") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("RestServiceType") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("TimeoutSeconds") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(100); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Username") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DatabaseType"); + + b.HasIndex("IsActive"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("Type"); + + b.ToTable("Credentials", (string)null); + }); + + modelBuilder.Entity("CredentialManager.Models.DataCouplerProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CreatedBy") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("DestinationCredentialId") + .HasColumnType("INTEGER"); + + b.Property("DestinationEndpoint") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("DestinationSchema") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("DestinationTable") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("DestinationType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("FieldMappingJson") + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("LastUsedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("SourceCredentialId") + .HasColumnType("INTEGER"); + + b.Property("SourceCustomQuery") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("SourceDatabaseName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SourceFilePath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("SourceKeyField") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SourceSchema") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SourceTable") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SourceType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("UseRecordAssociations") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("DestinationCredentialId"); + + b.HasIndex("DestinationType"); + + b.HasIndex("IsActive"); + + b.HasIndex("LastUsedAt"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("SourceCredentialId"); + + b.HasIndex("SourceType"); + + b.ToTable("DataCouplerProfiles", (string)null); + }); + + modelBuilder.Entity("CredentialManager.Models.KeyAssociation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AdditionalInfo") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Data_Hash") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("DestinationEntity") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("DestinationId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("DestinationKeyField") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("KeyValue") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("LastVerifiedAt") + .HasColumnType("TEXT"); + + b.Property("RestCredentialName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("SourceKeyField") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SourcesInfo") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("DestinationEntity"); + + b.HasIndex("IsActive"); + + b.HasIndex("KeyValue") + .HasDatabaseName("IX_KeyAssociations_KeyValue"); + + b.HasIndex("LastVerifiedAt"); + + b.HasIndex("RestCredentialName"); + + b.HasIndex("KeyValue", "DestinationEntity", "RestCredentialName") + .IsUnique() + .HasDatabaseName("IX_KeyAssociations_Unique"); + + b.ToTable("KeyAssociations", (string)null); + }); + + modelBuilder.Entity("CredentialManager.Models.ProfileSchedule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DailyTime") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("DayOfMonth") + .HasColumnType("INTEGER"); + + b.Property("DayOfWeek") + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("DestinationDatabaseOverride") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("ExecutionCount") + .HasColumnType("INTEGER"); + + b.Property("IntervalUnit") + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("IntervalValue") + .HasColumnType("INTEGER"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("LastExecutionMessage") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("LastExecutionRecordCount") + .HasColumnType("INTEGER"); + + b.Property("LastExecutionStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("LastExecutionTime") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("NextExecutionTime") + .HasColumnType("TEXT"); + + b.Property("ProfileId") + .HasColumnType("INTEGER"); + + b.Property("ScheduleType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("ScheduledDateTime") + .HasColumnType("TEXT"); + + b.Property("SourceDatabaseOverride") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ProfileId"); + + b.ToTable("ProfileSchedules"); + }); + + modelBuilder.Entity("CredentialManager.Models.ScheduleExecutionHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AdditionalInfo") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DestinationInfo") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("DestinationType") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("EndTime") + .HasColumnType("TEXT"); + + b.Property("ErrorDetails") + .HasMaxLength(5000) + .HasColumnType("TEXT"); + + b.Property("Message") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ProfileId") + .HasColumnType("INTEGER"); + + b.Property("ProfileName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("RecordsProcessed") + .HasColumnType("INTEGER"); + + b.Property("RecordsWithErrors") + .HasColumnType("INTEGER"); + + b.Property("ScheduleId") + .HasColumnType("INTEGER"); + + b.Property("SourceInfo") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("SourceType") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("StartTime") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("TriggerType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("TriggeredBy") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ProfileId"); + + b.HasIndex("ScheduleId"); + + b.HasIndex("StartTime"); + + b.HasIndex("Status"); + + b.HasIndex("TriggerType"); + + b.ToTable("ScheduleExecutionHistories", (string)null); + }); + + modelBuilder.Entity("CredentialManager.Models.DataCouplerProfile", b => + { + b.HasOne("CredentialManager.Models.CredentialEntity", "DestinationCredential") + .WithMany() + .HasForeignKey("DestinationCredentialId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("CredentialManager.Models.CredentialEntity", "SourceCredential") + .WithMany() + .HasForeignKey("SourceCredentialId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("DestinationCredential"); + + b.Navigation("SourceCredential"); + }); + + modelBuilder.Entity("CredentialManager.Models.ProfileSchedule", b => + { + b.HasOne("CredentialManager.Models.DataCouplerProfile", "Profile") + .WithMany() + .HasForeignKey("ProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Profile"); + }); + + modelBuilder.Entity("CredentialManager.Models.ScheduleExecutionHistory", b => + { + b.HasOne("CredentialManager.Models.ProfileSchedule", "Schedule") + .WithMany() + .HasForeignKey("ScheduleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Schedule"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/CredentialManager/Migrations/20251001224302_AddIntervalSchedulingFields.cs b/CredentialManager/Migrations/20251001224302_AddIntervalSchedulingFields.cs new file mode 100644 index 0000000..4e83c85 --- /dev/null +++ b/CredentialManager/Migrations/20251001224302_AddIntervalSchedulingFields.cs @@ -0,0 +1,39 @@ +๏ปฟusing Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CredentialManager.Migrations +{ + /// + public partial class AddIntervalSchedulingFields : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "IntervalUnit", + table: "ProfileSchedules", + type: "TEXT", + maxLength: 20, + nullable: true); + + migrationBuilder.AddColumn( + name: "IntervalValue", + table: "ProfileSchedules", + type: "INTEGER", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "IntervalUnit", + table: "ProfileSchedules"); + + migrationBuilder.DropColumn( + name: "IntervalValue", + table: "ProfileSchedules"); + } + } +} diff --git a/CredentialManager/Migrations/CredentialDbContextModelSnapshot.cs b/CredentialManager/Migrations/CredentialDbContextModelSnapshot.cs index cf770a2..2685223 100644 --- a/CredentialManager/Migrations/CredentialDbContextModelSnapshot.cs +++ b/CredentialManager/Migrations/CredentialDbContextModelSnapshot.cs @@ -320,6 +320,190 @@ namespace CredentialManager.Migrations b.ToTable("KeyAssociations", (string)null); }); + modelBuilder.Entity("CredentialManager.Models.ProfileSchedule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DailyTime") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("DayOfMonth") + .HasColumnType("INTEGER"); + + b.Property("DayOfWeek") + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("DestinationDatabaseOverride") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("ExecutionCount") + .HasColumnType("INTEGER"); + + b.Property("IntervalUnit") + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("IntervalValue") + .HasColumnType("INTEGER"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("LastExecutionMessage") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("LastExecutionRecordCount") + .HasColumnType("INTEGER"); + + b.Property("LastExecutionStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("LastExecutionTime") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("NextExecutionTime") + .HasColumnType("TEXT"); + + b.Property("ProfileId") + .HasColumnType("INTEGER"); + + b.Property("ScheduleType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("ScheduledDateTime") + .HasColumnType("TEXT"); + + b.Property("SourceDatabaseOverride") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ProfileId"); + + b.ToTable("ProfileSchedules"); + }); + + modelBuilder.Entity("CredentialManager.Models.ScheduleExecutionHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AdditionalInfo") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DestinationInfo") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("DestinationType") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("EndTime") + .HasColumnType("TEXT"); + + b.Property("ErrorDetails") + .HasMaxLength(5000) + .HasColumnType("TEXT"); + + b.Property("Message") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ProfileId") + .HasColumnType("INTEGER"); + + b.Property("ProfileName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("RecordsProcessed") + .HasColumnType("INTEGER"); + + b.Property("RecordsWithErrors") + .HasColumnType("INTEGER"); + + b.Property("ScheduleId") + .HasColumnType("INTEGER"); + + b.Property("SourceInfo") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("SourceType") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("StartTime") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("TriggerType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("TriggeredBy") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ProfileId"); + + b.HasIndex("ScheduleId"); + + b.HasIndex("StartTime"); + + b.HasIndex("Status"); + + b.HasIndex("TriggerType"); + + b.ToTable("ScheduleExecutionHistories", (string)null); + }); + modelBuilder.Entity("CredentialManager.Models.DataCouplerProfile", b => { b.HasOne("CredentialManager.Models.CredentialEntity", "DestinationCredential") @@ -336,6 +520,28 @@ namespace CredentialManager.Migrations b.Navigation("SourceCredential"); }); + + modelBuilder.Entity("CredentialManager.Models.ProfileSchedule", b => + { + b.HasOne("CredentialManager.Models.DataCouplerProfile", "Profile") + .WithMany() + .HasForeignKey("ProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Profile"); + }); + + modelBuilder.Entity("CredentialManager.Models.ScheduleExecutionHistory", b => + { + b.HasOne("CredentialManager.Models.ProfileSchedule", "Schedule") + .WithMany() + .HasForeignKey("ScheduleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Schedule"); + }); #pragma warning restore 612, 618 } } diff --git a/CredentialManager/Models/ProfileSchedule.cs b/CredentialManager/Models/ProfileSchedule.cs new file mode 100644 index 0000000..9de9e3c --- /dev/null +++ b/CredentialManager/Models/ProfileSchedule.cs @@ -0,0 +1,227 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace CredentialManager.Models; + +/// +/// Modello per la schedulazione dei profili Data Coupler +/// +public class ProfileSchedule +{ + [Key] + public int Id { get; set; } + + [Required] + [MaxLength(100)] + public string Name { get; set; } = string.Empty; + + [MaxLength(500)] + public string? Description { get; set; } + + // Relazione con il profilo + [Required] + public int ProfileId { get; set; } + + [ForeignKey(nameof(ProfileId))] + public virtual DataCouplerProfile Profile { get; set; } = null!; + + // Configurazione scheduling + [Required] + public bool IsEnabled { get; set; } = true; + + [Required] + [MaxLength(20)] + public string ScheduleType { get; set; } = string.Empty; // "once", "daily", "weekly", "monthly", "interval" + + public DateTime? ScheduledDateTime { get; set; } // Per schedulazioni "once" + + [MaxLength(10)] + public string? DailyTime { get; set; } // Format "HH:mm" per schedulazioni ricorrenti + + public int? DayOfWeek { get; set; } // 0-6 per schedulazioni settimanali (0=Domenica) + + public int? DayOfMonth { get; set; } // 1-31 per schedulazioni mensili + + // Configurazione per schedulazioni a intervalli + public int? IntervalValue { get; set; } // Valore dell'intervallo (es. 5, 10, 30) + + [MaxLength(20)] + public string? IntervalUnit { get; set; } // "seconds", "minutes", "hours", "days", "weeks", "months" + + // Tracking delle esecuzioni + public DateTime? LastExecutionTime { get; set; } + + public DateTime? NextExecutionTime { get; set; } + + public int ExecutionCount { get; set; } = 0; + + [MaxLength(20)] + public string LastExecutionStatus { get; set; } = string.Empty; // "success", "failed", "running" + + [MaxLength(1000)] + public string? LastExecutionMessage { get; set; } + + public int? LastExecutionRecordCount { get; set; } + + // Configurazione override per database sources + [MaxLength(100)] + public string? SourceDatabaseOverride { get; set; } + + [MaxLength(100)] + public string? DestinationDatabaseOverride { get; set; } + + // Metadati + [MaxLength(100)] + public string? CreatedBy { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + public DateTime? UpdatedAt { get; set; } + + public bool IsActive { get; set; } = true; + + // Metodi helper per calcolare la prossima esecuzione + public DateTime? CalculateNextExecution() + { + if (!IsEnabled || !IsActive) + return null; + + var now = DateTime.Now; + + return ScheduleType switch + { + "once" => ScheduledDateTime > now ? ScheduledDateTime : null, + "daily" when !string.IsNullOrEmpty(DailyTime) => CalculateNextDaily(now), + "weekly" when DayOfWeek.HasValue && !string.IsNullOrEmpty(DailyTime) => CalculateNextWeekly(now), + "monthly" when DayOfMonth.HasValue && !string.IsNullOrEmpty(DailyTime) => CalculateNextMonthly(now), + "interval" when IntervalValue.HasValue && !string.IsNullOrEmpty(IntervalUnit) => CalculateNextInterval(now), + _ => null + }; + } + + public DateTime? CalculateNextExecutionFromLast() + { + if (!IsEnabled || !IsActive) + return null; + + // Per intervalli, calcola dalla ultima esecuzione (o da ora se mai eseguito) + if (ScheduleType == "interval" && IntervalValue.HasValue && !string.IsNullOrEmpty(IntervalUnit)) + { + var baseTime = LastExecutionTime ?? DateTime.Now; + return CalculateNextInterval(baseTime); + } + + // Per altri tipi, usa CalculateNextExecution normale + return CalculateNextExecution(); + } + + private DateTime CalculateNextDaily(DateTime now) + { + var time = TimeSpan.Parse(DailyTime!); + var today = now.Date.Add(time); + return today > now ? today : today.AddDays(1); + } + + private DateTime CalculateNextWeekly(DateTime now) + { + var time = TimeSpan.Parse(DailyTime!); + var targetDayOfWeek = (DayOfWeek)DayOfWeek!; + + var daysUntilTarget = ((int)targetDayOfWeek - (int)now.DayOfWeek + 7) % 7; + if (daysUntilTarget == 0) + { + // รˆ oggi, controlla se l'orario รจ giร  passato + var todayAtTime = now.Date.Add(time); + if (todayAtTime > now) + return todayAtTime; + else + daysUntilTarget = 7; // Prossima settimana + } + + return now.Date.AddDays(daysUntilTarget).Add(time); + } + + private DateTime CalculateNextMonthly(DateTime now) + { + var time = TimeSpan.Parse(DailyTime!); + var targetDay = DayOfMonth!.Value; + + var thisMonth = new DateTime(now.Year, now.Month, Math.Min(targetDay, DateTime.DaysInMonth(now.Year, now.Month))).Add(time); + if (thisMonth > now) + return thisMonth; + + // Prossimo mese + var nextMonth = now.AddMonths(1); + return new DateTime(nextMonth.Year, nextMonth.Month, Math.Min(targetDay, DateTime.DaysInMonth(nextMonth.Year, nextMonth.Month))).Add(time); + } + + private DateTime CalculateNextInterval(DateTime baseTime) + { + if (!IntervalValue.HasValue || string.IsNullOrEmpty(IntervalUnit)) + return baseTime; + + return IntervalUnit.ToLower() switch + { + "seconds" => baseTime.AddSeconds(IntervalValue.Value), + "minutes" => baseTime.AddMinutes(IntervalValue.Value), + "hours" => baseTime.AddHours(IntervalValue.Value), + "days" => baseTime.AddDays(IntervalValue.Value), + "weeks" => baseTime.AddDays(IntervalValue.Value * 7), + "months" => baseTime.AddMonths(IntervalValue.Value), + _ => baseTime + }; + } + + /// + /// Ottiene una descrizione leggibile della schedulazione + /// + public string GetScheduleDescription() + { + return ScheduleType switch + { + "once" => $"Una volta il {ScheduledDateTime:dd/MM/yyyy HH:mm}", + "daily" => $"Ogni giorno alle {DailyTime}", + "weekly" => $"Ogni {GetDayOfWeekName(DayOfWeek)} alle {DailyTime}", + "monthly" => $"Il giorno {DayOfMonth} di ogni mese alle {DailyTime}", + "interval" => GetIntervalDescription(), + _ => "Non configurato" + }; + } + + private string GetIntervalDescription() + { + if (!IntervalValue.HasValue || string.IsNullOrEmpty(IntervalUnit)) + return "Intervallo non configurato"; + + var unit = IntervalUnit.ToLower() switch + { + "seconds" => IntervalValue.Value == 1 ? "secondo" : "secondi", + "minutes" => IntervalValue.Value == 1 ? "minuto" : "minuti", + "hours" => IntervalValue.Value == 1 ? "ora" : "ore", + "days" => IntervalValue.Value == 1 ? "giorno" : "giorni", + "weeks" => IntervalValue.Value == 1 ? "settimana" : "settimane", + "months" => IntervalValue.Value == 1 ? "mese" : "mesi", + _ => IntervalUnit + }; + + return $"Ogni {IntervalValue} {unit}"; + } + + private string GetDayOfWeekName(int? dayOfWeek) + { + if (!dayOfWeek.HasValue) + return "sconosciuto"; + + return ((DayOfWeek)dayOfWeek.Value).ToString() switch + { + "Monday" => "Lunedรฌ", + "Tuesday" => "Martedรฌ", + "Wednesday" => "Mercoledรฌ", + "Thursday" => "Giovedรฌ", + "Friday" => "Venerdรฌ", + "Saturday" => "Sabato", + "Sunday" => "Domenica", + _ => dayOfWeek.Value.ToString() + }; + } +} diff --git a/CredentialManager/Models/ScheduleExecutionHistory.cs b/CredentialManager/Models/ScheduleExecutionHistory.cs new file mode 100644 index 0000000..7b5a7e8 --- /dev/null +++ b/CredentialManager/Models/ScheduleExecutionHistory.cs @@ -0,0 +1,101 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace CredentialManager.Models; + +/// +/// Modello per lo storico delle esecuzioni delle schedulazioni +/// +public class ScheduleExecutionHistory +{ + [Key] + public int Id { get; set; } + + // Relazione con la schedulazione + [Required] + public int ScheduleId { get; set; } + + [ForeignKey(nameof(ScheduleId))] + public virtual ProfileSchedule Schedule { get; set; } = null!; + + // Relazione con il profilo (denormalizzato per storico) + [Required] + public int ProfileId { get; set; } + + [Required] + [MaxLength(200)] + public string ProfileName { get; set; } = string.Empty; + + // Informazioni dell'esecuzione + [Required] + public DateTime StartTime { get; set; } + + public DateTime? EndTime { get; set; } + + public TimeSpan? Duration => EndTime - StartTime; + + [Required] + [MaxLength(20)] + public string Status { get; set; } = string.Empty; // "success", "failed", "running", "cancelled" + + [MaxLength(2000)] + public string? Message { get; set; } + + public int RecordsProcessed { get; set; } = 0; + + public int? RecordsWithErrors { get; set; } + + // Dettagli dell'errore se presente + [MaxLength(5000)] + public string? ErrorDetails { get; set; } + + // Informazioni sul trigger + [Required] + [MaxLength(20)] + public string TriggerType { get; set; } = string.Empty; // "manual", "automatic" + + [MaxLength(100)] + public string? TriggeredBy { get; set; } + + // Configurazioni al momento dell'esecuzione (per storico) + [MaxLength(50)] + public string? SourceType { get; set; } + + [MaxLength(50)] + public string? DestinationType { get; set; } + + [MaxLength(500)] + public string? SourceInfo { get; set; } // Informazioni aggiuntive sulla sorgente utilizzata + + [MaxLength(500)] + public string? DestinationInfo { get; set; } // Informazioni aggiuntive sulla destinazione utilizzata + + // Metadati + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + [MaxLength(2000)] + public string? AdditionalInfo { get; set; } // JSON per informazioni aggiuntive + + // Helper methods + public bool IsCompleted => Status == "success" || Status == "failed" || Status == "cancelled"; + + public bool IsSuccessful => Status == "success"; + + public string GetStatusDisplayText() => Status switch + { + "success" => "Completato con successo", + "failed" => "Fallito", + "running" => "In esecuzione", + "cancelled" => "Cancellato", + _ => "Sconosciuto" + }; + + public string GetStatusColorClass() => Status switch + { + "success" => "text-success", + "failed" => "text-danger", + "running" => "text-primary", + "cancelled" => "text-warning", + _ => "text-muted" + }; +} \ No newline at end of file diff --git a/CredentialManager/Services/DatabaseInitializer.cs b/CredentialManager/Services/DatabaseInitializer.cs index 30e0de5..1e87f97 100644 --- a/CredentialManager/Services/DatabaseInitializer.cs +++ b/CredentialManager/Services/DatabaseInitializer.cs @@ -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"); + } + } } diff --git a/CredentialManager/Services/IProfileScheduleService.cs b/CredentialManager/Services/IProfileScheduleService.cs new file mode 100644 index 0000000..f079c68 --- /dev/null +++ b/CredentialManager/Services/IProfileScheduleService.cs @@ -0,0 +1,27 @@ +using CredentialManager.Models; + +namespace CredentialManager.Services; + +/// +/// Servizio per la gestione delle schedulazioni dei profili +/// +public interface IProfileScheduleService +{ + Task> GetAllSchedulesAsync(); + Task GetScheduleByIdAsync(int id); + Task CreateScheduleAsync(ProfileSchedule schedule); + Task UpdateScheduleAsync(ProfileSchedule schedule); + Task DeleteScheduleAsync(int id); + Task> GetActiveSchedulesAsync(); + Task> GetPendingExecutionsAsync(); + Task UpdateExecutionStatusAsync(int scheduleId, string status, string? message = null, int? recordCount = null); + Task UpdateNextExecutionTimeAsync(int scheduleId); + Task> GetAvailableProfilesAsync(); + + // Metodi per lo storico delle esecuzioni + Task CreateExecutionHistoryAsync(ScheduleExecutionHistory history); + Task UpdateExecutionHistoryAsync(ScheduleExecutionHistory history); + Task> GetExecutionHistoryAsync(int scheduleId, int? limit = null); + Task> GetRecentExecutionsAsync(int limit = 50); + Task GetExecutionByIdAsync(int executionId); +} \ No newline at end of file diff --git a/CredentialManager/Services/ProfileScheduleService.cs b/CredentialManager/Services/ProfileScheduleService.cs new file mode 100644 index 0000000..9aaa6b3 --- /dev/null +++ b/CredentialManager/Services/ProfileScheduleService.cs @@ -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 _logger; + + public ProfileScheduleService(CredentialDbContext context, ILogger logger) + { + _context = context ?? throw new ArgumentNullException(nameof(context)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task> 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 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 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 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 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> 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> 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 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> 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 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 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> 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)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> 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 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; + } + } +} diff --git a/CredentialManager/design_time_temp.db b/CredentialManager/design_time_temp.db index 26d93bda576cddaeec8ab5f52e6e562c33a7945d..87ce085e856243facecd4030e9a9b573db4b1d9a 100644 GIT binary patch delta 3938 zcmbVOYfMvD9PepsZ+lDc1r;w(xs-=i1pCy22$uFDjnHB#17lpN?FBn)yY*g(e5y5B zSh8$Bx_G~uecQe`VHwNzg)GaynXzQaG|S>lwq(X+$-bB@i`mY7_R?Cw-Gqj7{*UuJ zzw>`xzlU7kFx_gibZbiA(CwpxHCp@yzte&LgTHvd-oR0~s3+jhe#>g@Z@AZC@3lKf zr*pvV8E`uLTpq8h-|gwK+Xw7+3sgffpuxYw-^VBM7VH_efqjIfFb@XS>S}+tsl;VExtLC67DiKaI?43++57BHlVoG>ce))X zTuzsRw~=&d>KdI`WK-G9Sc+k@xn;q|X4Hj+a|D~?4yUW1w>dqT%g(3Lv}`Nr9zk^H zO1d6+As4S(rYd4t+vp2`1{8B^@D2PfK8HJS9rgtK61#&XFgLto1D5a8#lh~ch|21_ zyPc^^7Z$v4y_gE@QAi+d?ADztsa3rbvi4-CtIh!M=NkO^THWc#cxyAzoift%v3)?- zYgD=Ay)vtF`pv@@y;p+|Vi@)e{RExSdy#jMTEjilpC;P)t#L@Vs%z3Nfp2i0jl1yK zwM*J{uN!RYv>I;_HM-YU^o`o}nE~+Wo*u)D1_kpPWEE*LEaBr|9({z?o8B`1X!M$Y z(md6S!GIDz@aAn_OTKX6AVP$DLE%&?lccW&XJh36VE%FtnGwY3L^i`- z7B;vcKRCdh4}h+{lH>6>BL^qaocG^H`nScOF0%Z(0!UIV7r_ zbVzr2USOIhnK1H8{vr5PBR4z(nwz##pr(pD8vuug<;M;(RECeaZQ?=oY}w2NBtE_% zK0f~{`;~(tbRwHeij4D5zi*4(6dPw3nWEi9)NR>UBB>ZKH`l2GBAZb}uNkzhwPG8k zlYux(D_)BCE%hy=H9#|LDih~BCKSgn#Th!hMCWp;B=O*Clund%4(Nl&8vPq-#}CdVEeFjBg8bs9^46g9)cG`sk-~F zSKzJ+TXeP?#DZp?foPpw2yp?To!$wp2sX#^F<2dMWjCp=o9AyYI@~2@N!eELbRxt> zi2D3)P)ivUEFUxT@|+2ysPnaWRy^(scS#%4-BF@DFN{6a`H?503g9Z-*4M#Z%0*!q z%2z*{kLr@Pt$?;8#2GkV^vZloLfHdT!dfno8+_%UmY0Z88H;?DyC_4ma>9xbJ|FmC zS!h}^hRQsyE)k~34p?)fv7@RcKY%n=my+hrkSyJ93@LFb7t@MKMN;9~VaGEmiNQA+ zgvun9%5M;pT-aCluajcSVxSv-p4hGjN}4k%_A-^rWpj)$-P8#G2WS?~MB@B|NOg^y zgOKD!annL@zm5O{zgtuM&}=TYnao^`AJlM<{lF~9^uf%0RvdPqP+2u`4?VD_Up)?g zYAA4En*fyKNhTLo%2FDUTU(4uqV6#j!=y0mt9wrE)~3_g#GM=i9o)t!K-X7B!2=-7 ISN|US52OSDk^lez delta 284 zcmZoTz}c{XeS$o%ECT|_PSmmCVdVb}5)tM9&9!o4NfSSpe~5x#C{RR%bCbXmeggw@ zT_ZyU19K}QBP%0gJtIqVa|=szZ36=<0|Q>5GB&;g4E($JyZHn7W%yq59oQ@=u!e8* z0X-K+0XDw-4EzW9r|~E78}c*r-3LmY=G%PVPE~L_ivZ&v`Ar2Z2Lw1-EExE0`1tr< za;@YFXR+8=F_UrPf|Tut3>np#wp*AmUSMWo3f#=a@rK<5>@*)AUM@|L&4P?+4E)pi z`S@~qpYta2oa9mEE@VvOy1^yNnZ0hUP^ diff --git a/DataConnection/DB/EF/EFCoreDatabaseManager.cs b/DataConnection/DB/EF/EFCoreDatabaseManager.cs index d094961..51d716b 100644 --- a/DataConnection/DB/EF/EFCoreDatabaseManager.cs +++ b/DataConnection/DB/EF/EFCoreDatabaseManager.cs @@ -10,6 +10,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.Data.SqlClient; +using CredentialManager.Migrations; namespace DataConnection.EF; @@ -101,41 +102,87 @@ public class EFCoreDatabaseManager : IDatabaseManager return await _context.Set().FromSqlRaw(sql, parameters).ToListAsync(); } - public async Task>> ExecuteRawQueryAsync(string sql, params object[] parameters) + public async Task>> ExecuteRawQueryAsync(string sql, string databaseName, params object[] parameters) { - using var command = _context.Database.GetDbConnection().CreateCommand(); - command.CommandText = sql; - - // Aggiungi i parametri - for (int i = 0; i < parameters.Length; i++) + try { - var parameter = command.CreateParameter(); - parameter.ParameterName = $"@p{i}"; - parameter.Value = parameters[i] ?? DBNull.Value; - command.Parameters.Add(parameter); - } - - await _context.Database.OpenConnectionAsync(); - - var results = new List>(); - - using var reader = await command.ExecuteReaderAsync(); - - while (await reader.ReadAsync()) - { - var row = new Dictionary(); - - for (int i = 0; i < reader.FieldCount; i++) + // Assicurarsi che la connessione sia aperta + if (_context.Database.GetDbConnection().State != ConnectionState.Open) { - var columnName = reader.GetName(i); - var value = reader.IsDBNull(i) ? null : reader.GetValue(i); - row[columnName] = value ?? ""; // Usa stringa vuota invece di null + await _context.Database.OpenConnectionAsync(); } - results.Add(row); + var connection = _context.Database.GetDbConnection(); + // Debug: verifica il database attuale della connessione + Console.WriteLine($"ExecuteRawQueryAsync: Database connessione prima: {connection.Database}"); + Console.WriteLine($"ExecuteRawQueryAsync: Connection string: {connection.ConnectionString}"); + + // Estrai il database target dalla connection string del DbContext + var connectionString = _context.Database.GetConnectionString(); + Console.WriteLine($"ExecuteRawQueryAsync: Connection string completa: {connectionString}"); + + if (!string.IsNullOrEmpty(connectionString)) + { + //var builder = new SqlConnectionStringBuilder(connectionString); + var targetDatabase = databaseName; + + Console.WriteLine($"ExecuteRawQueryAsync: InitialCatalog dalla connection string: '{targetDatabase}'"); + Console.WriteLine($"ExecuteRawQueryAsync: Database corrente connessione: '{connection.Database}'"); + + // Se il database della connessione non corrisponde al target, forza il cambio + if (!string.IsNullOrEmpty(targetDatabase) && !string.Equals(connection.Database, targetDatabase, StringComparison.OrdinalIgnoreCase)) + { + Console.WriteLine($"ExecuteRawQueryAsync: MISMATCH RILEVATO! Forzando cambio database da '{connection.Database}' a '{targetDatabase}'"); + await connection.ChangeDatabaseAsync(targetDatabase); + Console.WriteLine($"ExecuteRawQueryAsync: Database connessione dopo cambio: '{connection.Database}'"); + } + else + { + Console.WriteLine($"ExecuteRawQueryAsync: Database giร  corretto: '{connection.Database}' = '{targetDatabase}'"); + } + } + else + { + Console.WriteLine("ExecuteRawQueryAsync: ATTENZIONE! Connection string รจ vuota!"); + } + + using var command = connection.CreateCommand(); + command.CommandText = sql; + + // Aggiungi i parametri + for (int i = 0; i < parameters.Length; i++) + { + var parameter = command.CreateParameter(); + parameter.ParameterName = $"@p{i}"; + parameter.Value = parameters[i] ?? DBNull.Value; + command.Parameters.Add(parameter); + } + + var results = new List>(); + + using var reader = await command.ExecuteReaderAsync(); + + while (await reader.ReadAsync()) + { + var row = new Dictionary(); + + for (int i = 0; i < reader.FieldCount; i++) + { + var columnName = reader.GetName(i); + var value = reader.IsDBNull(i) ? null : reader.GetValue(i); + row[columnName] = value ?? ""; // Usa stringa vuota invece di null + } + + results.Add(row); + } + + return results; + } + catch (Exception ex) + { + Console.WriteLine($"Errore nell'esecuzione della query raw: {ex.Message}"); + throw; } - - return results; } public async Task ExecuteCommandAsync(string sql, params object[] parameters) @@ -148,15 +195,20 @@ public class EFCoreDatabaseManager : IDatabaseManager try { // Assicurarsi che il contesto sia connesso - await _context.Database.OpenConnectionAsync(); + if (_context.Database.GetDbConnection().State != ConnectionState.Open) + { + await _context.Database.OpenConnectionAsync(); + } // Usa la factory per ottenere il provider appropriato in base al tipo di database var schemaProvider = DatabaseSchemaProviderFactory.CreateProvider(_options.DatabaseType); - // Usa il provider per ottenere lo schema + // Usa il provider per ottenere lo schema utilizzando la connection string corrente del DbContext var connectionString = _context.Database.GetConnectionString(); if (connectionString == null) throw new InvalidOperationException("Connection string is null"); + + Console.WriteLine($"GetDatabaseSchemaAsync: Utilizzo connection string: {connectionString}"); var result = await schemaProvider.GetDatabaseSchemaAsync(connectionString); @@ -180,7 +232,7 @@ public class EFCoreDatabaseManager : IDatabaseManager // Usa la factory per ottenere il provider appropriato in base al tipo di database var schemaProvider = DatabaseSchemaProviderFactory.CreateProvider(_options.DatabaseType); - // Usa il provider per ottenere la lista dei database + // Usa il provider per ottenere la lista dei database utilizzando la connection string corrente del DbContext var connectionString = _context.Database.GetConnectionString(); if (connectionString == null) throw new InvalidOperationException("Connection string is null"); @@ -207,7 +259,7 @@ public class EFCoreDatabaseManager : IDatabaseManager // Usa la factory per ottenere il provider appropriato in base al tipo di database var schemaProvider = DatabaseSchemaProviderFactory.CreateProvider(_options.DatabaseType); - // Usa il provider per ottenere la lista delle tabelle + // Usa il provider per ottenere la lista delle tabelle utilizzando la connection string corrente del DbContext var connectionString = _context.Database.GetConnectionString(); if (connectionString == null) throw new InvalidOperationException("Connection string is null"); @@ -235,7 +287,7 @@ public class EFCoreDatabaseManager : IDatabaseManager // Usa la factory per ottenere il provider appropriato in base al tipo di database var schemaProvider = DatabaseSchemaProviderFactory.CreateProvider(_options.DatabaseType); - // Usa il provider per ottenere lo schema della tabella + // Usa il provider per ottenere lo schema della tabella utilizzando la connection string corrente del DbContext var connectionString = _context.Database.GetConnectionString(); if (connectionString == null) throw new InvalidOperationException("Connection string is null"); @@ -257,14 +309,45 @@ public class EFCoreDatabaseManager : IDatabaseManager { var records = new List>(); - // Usa la stessa connection string utilizzata per il discovery dello schema - var connectionString = _context.Database.GetConnectionString(); - if (connectionString == null) - throw new InvalidOperationException("Connection string is null"); + // Usa la connessione del DbContext che รจ stata aggiornata se รจ stato chiamato ChangeDatabaseAsync + if (_context.Database.GetDbConnection().State != ConnectionState.Open) + { + await _context.Database.OpenConnectionAsync(); + } - // Determina il tipo di connessione in base al DatabaseType - using var connection = CreateConnection(connectionString); - await connection.OpenAsync(); + var connection = _context.Database.GetDbConnection(); + + // Debug: verifica il database attuale della connessione + Console.WriteLine($"GetAllRecordsAsync: Database connessione prima: {connection.Database}"); + + // Estrai il database target dalla connection string del DbContext + var connectionString = _context.Database.GetConnectionString(); + Console.WriteLine($"GetAllRecordsAsync: Connection string completa: {connectionString}"); + + if (!string.IsNullOrEmpty(connectionString)) + { + var builder = new SqlConnectionStringBuilder(connectionString); + var targetDatabase = builder.InitialCatalog; + + Console.WriteLine($"GetAllRecordsAsync: InitialCatalog dalla connection string: '{targetDatabase}'"); + Console.WriteLine($"GetAllRecordsAsync: Database corrente connessione: '{connection.Database}'"); + + // Se il database della connessione non corrisponde al target, forza il cambio + if (!string.IsNullOrEmpty(targetDatabase) && !string.Equals(connection.Database, targetDatabase, StringComparison.OrdinalIgnoreCase)) + { + Console.WriteLine($"GetAllRecordsAsync: MISMATCH RILEVATO! Forzando cambio database da '{connection.Database}' a '{targetDatabase}'"); + await connection.ChangeDatabaseAsync(targetDatabase); + Console.WriteLine($"GetAllRecordsAsync: Database connessione dopo cambio: '{connection.Database}'"); + } + else + { + Console.WriteLine($"GetAllRecordsAsync: Database giร  corretto: '{connection.Database}' = '{targetDatabase}'"); + } + } + else + { + Console.WriteLine("GetAllRecordsAsync: ATTENZIONE! Connection string รจ vuota!"); + } using var command = connection.CreateCommand(); @@ -319,9 +402,13 @@ public class EFCoreDatabaseManager : IDatabaseManager if (currentConnectionString == null) throw new InvalidOperationException("Connection string is null"); + Console.WriteLine($"ChangeDatabaseAsync: Connessione corrente: {currentConnectionString}"); + // Crea una nuova connection string con il database specificato var newConnectionString = UpdateConnectionStringDatabase(currentConnectionString, databaseName); + Console.WriteLine($"ChangeDatabaseAsync: Nuova connessione: {newConnectionString}"); + // Ricrea il contesto con la nuova connection string var optionsBuilder = new DbContextOptionsBuilder(); @@ -350,6 +437,8 @@ public class EFCoreDatabaseManager : IDatabaseManager // Testa la connessione al nuovo database await _context.Database.OpenConnectionAsync(); + + Console.WriteLine($"ChangeDatabaseAsync: Connessione aggiornata verificata: {_context.Database.GetConnectionString()}"); } catch (Exception ex) { @@ -406,12 +495,45 @@ public class EFCoreDatabaseManager : IDatabaseManager { try { - var connectionString = _context.Database.GetConnectionString(); - if (connectionString == null) - throw new InvalidOperationException("Connection string is null"); + // Usa la connessione del DbContext che รจ stata aggiornata se รจ stato chiamato ChangeDatabaseAsync + if (_context.Database.GetDbConnection().State != ConnectionState.Open) + { + await _context.Database.OpenConnectionAsync(); + } - using var connection = CreateConnection(connectionString); - await connection.OpenAsync(); + var connection = _context.Database.GetDbConnection(); + + // Debug: verifica il database attuale della connessione + Console.WriteLine($"GetPrimaryKeyFieldAsync: Database connessione prima: {connection.Database}"); + + // Estrai il database target dalla connection string del DbContext + var connectionString = _context.Database.GetConnectionString(); + Console.WriteLine($"GetPrimaryKeyFieldAsync: Connection string completa: {connectionString}"); + + if (!string.IsNullOrEmpty(connectionString)) + { + var builder = new SqlConnectionStringBuilder(connectionString); + var targetDatabase = builder.InitialCatalog; + + Console.WriteLine($"GetPrimaryKeyFieldAsync: InitialCatalog dalla connection string: '{targetDatabase}'"); + Console.WriteLine($"GetPrimaryKeyFieldAsync: Database corrente connessione: '{connection.Database}'"); + + // Se il database della connessione non corrisponde al target, forza il cambio + if (!string.IsNullOrEmpty(targetDatabase) && !string.Equals(connection.Database, targetDatabase, StringComparison.OrdinalIgnoreCase)) + { + Console.WriteLine($"GetPrimaryKeyFieldAsync: MISMATCH RILEVATO! Forzando cambio database da '{connection.Database}' a '{targetDatabase}'"); + await connection.ChangeDatabaseAsync(targetDatabase); + Console.WriteLine($"GetPrimaryKeyFieldAsync: Database connessione dopo cambio: '{connection.Database}'"); + } + else + { + Console.WriteLine($"GetPrimaryKeyFieldAsync: Database giร  corretto: '{connection.Database}' = '{targetDatabase}'"); + } + } + else + { + Console.WriteLine("GetPrimaryKeyFieldAsync: ATTENZIONE! Connection string รจ vuota!"); + } using var command = connection.CreateCommand(); diff --git a/DataConnection/DB/Interfaces/IDatabaseManager.cs b/DataConnection/DB/Interfaces/IDatabaseManager.cs index cb4b045..abc1a3a 100644 --- a/DataConnection/DB/Interfaces/IDatabaseManager.cs +++ b/DataConnection/DB/Interfaces/IDatabaseManager.cs @@ -44,7 +44,7 @@ public interface IDatabaseManager : IDisposable /// /// Esegue una query SQL raw e restituisce i risultati come dictionary /// - Task>> ExecuteRawQueryAsync(string sql, params object[] parameters); + Task>> ExecuteRawQueryAsync(string sql, string databaseName = "", params object[] parameters); /// /// Esegue un comando SQL che non restituisce risultati diff --git a/DataConnection/REST/Implementations/SalesforceServiceClient.cs b/DataConnection/REST/Implementations/SalesforceServiceClient.cs index c20586d..c67ef61 100644 --- a/DataConnection/REST/Implementations/SalesforceServiceClient.cs +++ b/DataConnection/REST/Implementations/SalesforceServiceClient.cs @@ -558,6 +558,652 @@ namespace DataConnection.REST.Implementations Console.WriteLine($"Error during Salesforce entity search: {ex.Message}"); return new List>(); } + } + + /// + /// Executes multiple queries using Salesforce Composite API for efficient data extraction + /// + /// List of SOQL queries to execute + /// Cancellation token + /// List of query results mapped by query index + public async Task> BatchExecuteQueriesAsync(List queries, CancellationToken cancellationToken = default) + { + if (!IsAuthenticated()) + { + Console.WriteLine("Error: Not authenticated to Salesforce. Cannot perform batch queries."); + return new List(); + } + + if (!queries.Any()) + { + return new List(); + } + + // Salesforce limit: max 25 operations per composite request + const int maxBatchSize = 25; + + // Split into batches of 25 + var batches = new List<(List batch, int startIndex, int batchNumber)>(); + for (int i = 0; i < queries.Count; i += maxBatchSize) + { + var batch = queries.Skip(i).Take(maxBatchSize).ToList(); + var batchNumber = (i / maxBatchSize) + 1; + batches.Add((batch, i, batchNumber)); + } + + var totalBatches = batches.Count; + Console.WriteLine($"--- Starting parallel execution of {totalBatches} query batch(es) with {queries.Count} total queries ---"); + + // Execute all batches in parallel + var batchTasks = batches.Select(async b => + { + Console.WriteLine($"--- Processing Query Batch {b.batchNumber}/{totalBatches}: {b.batch.Count} queries (parallel) ---"); + return await ExecuteQueryBatchAsync(b.batch, b.startIndex, cancellationToken); + }); + + var batchResults = await Task.WhenAll(batchTasks); + + // Aggregate all results maintaining original order + var allResults = new List(); + foreach (var result in batchResults) + { + allResults.AddRange(result); + } + + Console.WriteLine($"All query batches completed: {allResults.Count(r => r.Success)} success, {allResults.Count(r => !r.Success)} failed"); + return allResults.OrderBy(r => r.QueryIndex).ToList(); + } + + private async Task> ExecuteQueryBatchAsync(List queries, int startIndex, CancellationToken cancellationToken) + { + try + { + // Salesforce Composite API endpoint + var compositeUri = $"{_instanceUrl}/services/data/v60.0/composite/"; + + // Build composite request + var compositeRequest = new SalesforceCompositeRequest(); + + for (int i = 0; i < queries.Count; i++) + { + var encodedQuery = Uri.EscapeDataString(queries[i]); + var subrequest = new SalesforceCompositeSubRequest + { + Method = "GET", + Url = $"/services/data/v60.0/query/?q={encodedQuery}", + ReferenceId = $"query_{startIndex + i}" + }; + compositeRequest.CompositeRequest.Add(subrequest); + } + + var jsonContent = new StringContent( + JsonSerializer.Serialize(compositeRequest, SalesforceJsonOptions), + System.Text.Encoding.UTF8, + "application/json" + ); + + var response = await _httpClient.PostAsync(compositeUri, jsonContent, cancellationToken); + + if (!response.IsSuccessStatusCode) + { + var errorContent = await response.Content.ReadAsStringAsync(cancellationToken); + Console.WriteLine($"Salesforce Batch Query failed: {response.StatusCode}"); + Console.WriteLine($"Error details: {errorContent}"); + + // Return error results for all queries in this batch + return queries.Select((query, index) => new BatchQueryResult + { + QueryIndex = startIndex + index, + Query = query, + Success = false, + ErrorMessage = $"Batch operation failed: {response.StatusCode} - {errorContent}", + Records = new List>() + }).ToList(); + } + + var responseContent = await response.Content.ReadAsStringAsync(cancellationToken); + var compositeResponse = JsonSerializer.Deserialize(responseContent, SalesforceJsonOptions); + + var results = new List(); + + if (compositeResponse?.CompositeResponse != null) + { + for (int i = 0; i < compositeResponse.CompositeResponse.Count; i++) + { + var subResponse = compositeResponse.CompositeResponse[i]; + var originalQuery = i < queries.Count ? queries[i] : ""; + + var result = new BatchQueryResult + { + QueryIndex = startIndex + i, + Query = originalQuery, + Success = subResponse.HttpStatusCode >= 200 && subResponse.HttpStatusCode < 300 + }; + + if (result.Success && subResponse.Body != null) + { + try + { + if (subResponse.Body is JsonElement bodyElement) + { + var queryResponse = JsonSerializer.Deserialize(bodyElement.GetRawText(), SalesforceJsonOptions); + result.Records = queryResponse?.Records ?? new List>(); + result.TotalSize = queryResponse?.TotalSize ?? 0; + result.NextRecordsUrl = queryResponse?.NextRecordsUrl; + } + } + catch (JsonException ex) + { + result.Success = false; + result.ErrorMessage = $"Failed to parse query response: {ex.Message}"; + result.Records = new List>(); + } + } + else + { + result.ErrorMessage = subResponse.Body?.ToString() ?? "Unknown error"; + result.Records = new List>(); + } + + results.Add(result); + } + } + + return results; + } + catch (Exception ex) + { + Console.WriteLine($"Error during Salesforce batch query: {ex.Message}"); + + // Return error results for all queries in this batch + return queries.Select((query, index) => new BatchQueryResult + { + QueryIndex = startIndex + index, + Query = query, + Success = false, + ErrorMessage = ex.Message, + Records = new List>() + }).ToList(); + } + } + + /// + /// Finds multiple entities by different key fields using batch queries for improved performance + /// + /// The name of the SObject to search + /// List of key field combinations to search for + /// Cancellation token + /// Dictionary mapping original search index to found entities + public async Task>>> BatchFindEntitiesByKeysAsync(string entityName, List> keyFieldsList, CancellationToken cancellationToken = default) + { + try + { + Console.WriteLine($"--- Starting Salesforce Batch Entity Search: {entityName} ---"); + Console.WriteLine($"Searching for {keyFieldsList.Count} different key combinations"); + + if (!await EnsureAuthenticatedAsync(cancellationToken)) + { + Console.WriteLine("Authentication failed for batch entity search"); + return new Dictionary>>(); + } + + // Build queries for each key field combination + var queries = new List(); + for (int i = 0; i < keyFieldsList.Count; i++) + { + var keyFields = keyFieldsList[i]; + if (!keyFields.Any()) continue; + + var whereConditions = keyFields.Select(kvp => + { + var value = kvp.Value?.ToString() ?? ""; + if (kvp.Value is string) + { + value = $"'{value.Replace("'", "\\'")}'"; + } + return $"{kvp.Key} = {value}"; + }); + + var query = $"SELECT Id FROM {entityName} WHERE {string.Join(" AND ", whereConditions)}"; + queries.Add(query); + Console.WriteLine($"Query {i}: {query}"); + } + + if (!queries.Any()) + { + Console.WriteLine("No valid queries generated for batch search"); + return new Dictionary>>(); + } + + // Execute batch queries + var batchResults = await BatchExecuteQueriesAsync(queries, cancellationToken); + + // Map results back to original indices + var results = new Dictionary>>(); + foreach (var batchResult in batchResults) + { + results[batchResult.QueryIndex] = batchResult.Records; + } + + var totalFound = results.Values.Sum(list => list.Count); + Console.WriteLine($"Batch entity search completed: {totalFound} total entities found across {results.Count} queries"); + + return results; + } + catch (Exception ex) + { + Console.WriteLine($"Error during Salesforce batch entity search: {ex.Message}"); + return new Dictionary>>(); + } + } + + /// + /// Extracts multiple entities by their IDs using batch queries + /// + /// The name of the SObject to retrieve + /// List of entity IDs to retrieve + /// Fields to select (if null, selects all fields) + /// Cancellation token + /// List of retrieved entities + public async Task>> BatchGetEntitiesByIdsAsync(string entityName, List entityIds, List? fieldsToSelect = null, CancellationToken cancellationToken = default) + { + try + { + Console.WriteLine($"--- Starting Salesforce Batch Entity Retrieval: {entityName} ---"); + Console.WriteLine($"Retrieving {entityIds.Count} entities by ID"); + + if (!await EnsureAuthenticatedAsync(cancellationToken)) + { + Console.WriteLine("Authentication failed for batch entity retrieval"); + return new List>(); + } + + if (!entityIds.Any()) + { + return new List>(); + } + + // Determine fields to select + string selectFields = "*"; + if (fieldsToSelect?.Any() == true) + { + selectFields = string.Join(", ", fieldsToSelect); + } + else + { + // If no specific fields requested, get basic fields plus Id + selectFields = "Id"; // Start with Id, add more fields if needed + + // Optionally, you could get entity metadata to select all fields + // For now, we'll use a basic set of common fields + var commonFields = new[] { "Name", "CreatedDate", "LastModifiedDate" }; + selectFields = $"Id, {string.Join(", ", commonFields)}"; + } + + // Create batch queries - we can use IN clause to group multiple IDs per query + // Salesforce SOQL IN clause can handle up to 4000 characters + const int maxIdsPerQuery = 200; // Conservative limit to avoid URL length issues + + var queries = new List(); + for (int i = 0; i < entityIds.Count; i += maxIdsPerQuery) + { + var batchIds = entityIds.Skip(i).Take(maxIdsPerQuery); + var idList = string.Join("','", batchIds.Select(id => id.Replace("'", "\\'"))); + var query = $"SELECT {selectFields} FROM {entityName} WHERE Id IN ('{idList}')"; + queries.Add(query); + } + + Console.WriteLine($"Created {queries.Count} batch queries for {entityIds.Count} entity IDs"); + + // Execute batch queries + var batchResults = await BatchExecuteQueriesAsync(queries, cancellationToken); + + // Aggregate all records from all queries + var allRecords = new List>(); + foreach (var batchResult in batchResults.Where(r => r.Success)) + { + allRecords.AddRange(batchResult.Records); + } + + Console.WriteLine($"Successfully retrieved {allRecords.Count} entities out of {entityIds.Count} requested"); + + return allRecords; + } + catch (Exception ex) + { + Console.WriteLine($"Error during Salesforce batch entity retrieval: {ex.Message}"); + return new List>(); + } + } + + /// + /// Extracts all records from an entity using pagination and batch processing + /// + /// The name of the SObject to extract + /// Specific fields to select (if null, uses common fields) + /// Optional WHERE clause for filtering + /// Maximum number of records to retrieve (0 = no limit) + /// Cancellation token + /// List of all extracted records + public async Task>> ExtractAllEntitiesAsync(string entityName, List? fieldsToSelect = null, string? whereClause = null, int maxRecords = 0, CancellationToken cancellationToken = default) + { + try + { + Console.WriteLine($"--- Starting Salesforce Full Entity Extraction: {entityName} ---"); + + if (!await EnsureAuthenticatedAsync(cancellationToken)) + { + Console.WriteLine("Authentication failed for entity extraction"); + return new List>(); + } + + // Determine fields to select + string selectFields = "*"; + if (fieldsToSelect?.Any() == true) + { + selectFields = string.Join(", ", fieldsToSelect); + } + else + { + // Use common fields if none specified + selectFields = "Id"; // Always include Id + + // Get entity details to determine available fields + var entityDetails = await DiscoverEntityDetailsAsync(entityName, cancellationToken); + if (entityDetails?.Properties?.Any() == true) + { + // Select up to 10 most common fields to avoid URL length limits + var availableFields = entityDetails.Properties + .Where(p => !string.IsNullOrEmpty(p.Name)) + .Take(10) + .Select(p => p.Name); + selectFields = string.Join(", ", availableFields); + } + } + + // Build initial query + var query = $"SELECT {selectFields} FROM {entityName}"; + if (!string.IsNullOrWhiteSpace(whereClause)) + { + query += $" WHERE {whereClause}"; + } + query += " ORDER BY Id"; // Add ordering for consistent pagination + + Console.WriteLine($"Initial extraction query: {query}"); + + var allRecords = new List>(); + var currentQuery = query; + var hasMore = true; + var pageCount = 0; + + while (hasMore && (maxRecords == 0 || allRecords.Count < maxRecords)) + { + pageCount++; + Console.WriteLine($"Extracting page {pageCount}..."); + + var encodedQuery = Uri.EscapeDataString(currentQuery); + var queryEndpoint = $"/services/data/v60.0/query/?q={encodedQuery}"; + + var response = await GetAsync($"{_instanceUrl}{queryEndpoint}", cancellationToken); + + if (response?.Records != null) + { + var pageRecords = response.Records; + + // Apply max records limit if specified + if (maxRecords > 0) + { + var remainingCapacity = maxRecords - allRecords.Count; + if (remainingCapacity < pageRecords.Count) + { + pageRecords = pageRecords.Take(remainingCapacity).ToList(); + } + } + + allRecords.AddRange(pageRecords); + Console.WriteLine($"Page {pageCount}: Retrieved {pageRecords.Count} records, total: {allRecords.Count}"); + + // Check if there are more records + hasMore = !response.Done && !string.IsNullOrEmpty(response.NextRecordsUrl); + + if (hasMore && response.NextRecordsUrl != null) + { + // Use the nextRecordsUrl for the next query + currentQuery = response.NextRecordsUrl.Replace($"{_instanceUrl}/services/data/v60.0/query/", ""); + currentQuery = Uri.UnescapeDataString(currentQuery); + } + } + else + { + Console.WriteLine($"No response received for page {pageCount}"); + hasMore = false; + } + + // Prevent infinite loops + if (pageCount > 1000) + { + Console.WriteLine("Maximum page limit reached (1000), stopping extraction"); + break; + } + } + + Console.WriteLine($"Entity extraction completed: {allRecords.Count} total records extracted in {pageCount} pages"); + return allRecords; + } + catch (Exception ex) + { + Console.WriteLine($"Error during Salesforce entity extraction: {ex.Message}"); + return new List>(); + } + } + + /// + /// Extracts entities using multiple parallel queries with different criteria + /// Useful for extracting large datasets by splitting them by date ranges or other criteria + /// + /// The name of the SObject to extract + /// Specific fields to select + /// List of WHERE clauses for parallel extraction + /// Maximum records per individual query + /// Cancellation token + /// Combined list of all extracted records + public async Task>> ExtractEntitiesParallelAsync(string entityName, List? fieldsToSelect, List whereClauses, int maxRecordsPerQuery = 0, CancellationToken cancellationToken = default) + { + try + { + Console.WriteLine($"--- Starting Salesforce Parallel Entity Extraction: {entityName} ---"); + Console.WriteLine($"Using {whereClauses.Count} parallel extraction criteria"); + + if (!await EnsureAuthenticatedAsync(cancellationToken)) + { + Console.WriteLine("Authentication failed for parallel entity extraction"); + return new List>(); + } + + // Determine fields to select + string selectFields = "Id"; + if (fieldsToSelect?.Any() == true) + { + selectFields = string.Join(", ", fieldsToSelect); + } + + // Create extraction tasks for each WHERE clause + var extractionTasks = whereClauses.Select(async (whereClause, index) => + { + try + { + Console.WriteLine($"Starting parallel extraction {index + 1}/{whereClauses.Count}: {whereClause}"); + var records = await ExtractAllEntitiesAsync(entityName, fieldsToSelect, whereClause, maxRecordsPerQuery, cancellationToken); + Console.WriteLine($"Parallel extraction {index + 1} completed: {records.Count} records"); + return records; + } + catch (Exception ex) + { + Console.WriteLine($"Error in parallel extraction {index + 1}: {ex.Message}"); + return new List>(); + } + }).ToList(); + + // Wait for all parallel extractions to complete + var allResults = await Task.WhenAll(extractionTasks); + + // Combine all results + var combinedRecords = new List>(); + foreach (var result in allResults) + { + combinedRecords.AddRange(result); + } + + // Remove potential duplicates based on Id field + var deduplicatedRecords = combinedRecords + .GroupBy(record => record.ContainsKey("Id") ? record["Id"]?.ToString() : Guid.NewGuid().ToString()) + .Select(group => group.First()) + .ToList(); + + var duplicatesRemoved = combinedRecords.Count - deduplicatedRecords.Count; + if (duplicatesRemoved > 0) + { + Console.WriteLine($"Removed {duplicatesRemoved} duplicate records"); + } + + Console.WriteLine($"Parallel entity extraction completed: {deduplicatedRecords.Count} unique records from {whereClauses.Count} parallel queries"); + return deduplicatedRecords; + } + catch (Exception ex) + { + Console.WriteLine($"Error during Salesforce parallel entity extraction: {ex.Message}"); + return new List>(); + } + } + + /// + /// Helper method to split large datasets into date-based chunks for parallel extraction + /// + /// Start date for extraction + /// End date for extraction + /// Name of the date field to use for splitting (e.g., "CreatedDate", "LastModifiedDate") + /// Size of each date chunk in days + /// List of WHERE clauses for parallel extraction + public List CreateDateBasedWhereClauses(DateTime startDate, DateTime endDate, string dateFieldName = "CreatedDate", int chunkSizeInDays = 30) + { + var whereClauses = new List(); + var currentDate = startDate; + + while (currentDate < endDate) + { + var chunkEndDate = currentDate.AddDays(chunkSizeInDays); + if (chunkEndDate > endDate) + { + chunkEndDate = endDate; + } + + var startDateString = currentDate.ToString("yyyy-MM-ddTHH:mm:ssZ"); + var endDateString = chunkEndDate.ToString("yyyy-MM-ddTHH:mm:ssZ"); + + var whereClause = $"{dateFieldName} >= {startDateString} AND {dateFieldName} < {endDateString}"; + whereClauses.Add(whereClause); + + currentDate = chunkEndDate; + } + + Console.WriteLine($"Created {whereClauses.Count} date-based chunks for parallel extraction between {startDate:yyyy-MM-dd} and {endDate:yyyy-MM-dd}"); + return whereClauses; + } + + /// + /// High-performance method to extract large datasets by automatically splitting them into optimal chunks + /// + /// The name of the SObject to extract + /// Specific fields to select + /// Base WHERE clause (optional) + /// Maximum total records to extract (0 = no limit) + /// Cancellation token + /// List of all extracted records + public async Task>> ExtractLargeDatasetAsync(string entityName, List? fieldsToSelect = null, string? baseWhereClause = null, int maxRecords = 0, CancellationToken cancellationToken = default) + { + try + { + Console.WriteLine($"--- Starting Large Dataset Extraction: {entityName} ---"); + + // First, try to get a count to determine if we need parallel processing + var countQuery = $"SELECT COUNT() FROM {entityName}"; + if (!string.IsNullOrWhiteSpace(baseWhereClause)) + { + countQuery += $" WHERE {baseWhereClause}"; + } + + Console.WriteLine($"Checking dataset size with query: {countQuery}"); + + try + { + var countResponse = await GetAsync>($"{_instanceUrl}/services/data/v60.0/query/?q={Uri.EscapeDataString(countQuery)}", cancellationToken); + + if (countResponse?.ContainsKey("totalSize") == true && int.TryParse(countResponse["totalSize"].ToString(), out int totalRecords)) + { + Console.WriteLine($"Dataset contains approximately {totalRecords} records"); + + // If dataset is large (>10,000 records), use parallel extraction + if (totalRecords > 10000) + { + Console.WriteLine("Large dataset detected, using parallel extraction with date-based chunking"); + + // Create date-based chunks for the last 2 years by default + var endDate = DateTime.UtcNow; + var startDate = endDate.AddYears(-2); + + var whereClauses = CreateDateBasedWhereClauses(startDate, endDate, "CreatedDate", 7); // 7-day chunks + + // Add base WHERE clause to each chunk if provided + if (!string.IsNullOrWhiteSpace(baseWhereClause)) + { + whereClauses = whereClauses.Select(wc => $"({baseWhereClause}) AND ({wc})").ToList(); + } + + return await ExtractEntitiesParallelAsync(entityName, fieldsToSelect, whereClauses, maxRecords / whereClauses.Count, cancellationToken); + } + } + } + catch (Exception ex) + { + Console.WriteLine($"Could not determine dataset size, proceeding with standard extraction: {ex.Message}"); + } + + // For smaller datasets or if count failed, use standard extraction + Console.WriteLine("Using standard sequential extraction"); + return await ExtractAllEntitiesAsync(entityName, fieldsToSelect, baseWhereClause, maxRecords, cancellationToken); + } + catch (Exception ex) + { + Console.WriteLine($"Error during large dataset extraction: {ex.Message}"); + return new List>(); + } + } + + /// + /// Optimized method to extract recently modified entities using batch operations + /// + /// The name of the SObject to extract + /// Specific fields to select + /// Number of hours back to look for modifications (default: 24) + /// Cancellation token + /// List of recently modified entities + public async Task>> ExtractRecentlyModifiedAsync(string entityName, List? fieldsToSelect = null, int hoursBack = 24, CancellationToken cancellationToken = default) + { + try + { + var startTime = DateTime.UtcNow.AddHours(-hoursBack); + var whereClause = $"LastModifiedDate >= {startTime:yyyy-MM-ddTHH:mm:ssZ}"; + + Console.WriteLine($"--- Extracting recently modified {entityName} (last {hoursBack} hours) ---"); + Console.WriteLine($"Using WHERE clause: {whereClause}"); + + return await ExtractAllEntitiesAsync(entityName, fieldsToSelect, whereClause, 0, cancellationToken); + } + catch (Exception ex) + { + Console.WriteLine($"Error during recent entities extraction: {ex.Message}"); + return new List>(); + } } /// /// Deletes an entity in Salesforce by its ID. /// @@ -804,6 +1450,7 @@ namespace DataConnection.REST.Implementations [JsonPropertyName("fields")] public List Fields { get; set; } = new List(); } /// /// Finds entities by required fields to detect duplicates. + /// Now uses batch operations for improved performance when checking multiple field combinations. /// /// The name of the entity to search. /// The required fields and their values to search for. @@ -825,44 +1472,18 @@ namespace DataConnection.REST.Implementations { Console.WriteLine("No required fields provided for duplicate search"); return new List>(); - } // Build WHERE clause with required fields - var whereConditions = new List(); - foreach (var field in requiredFields) - { - if (field.Value != null) - { - var value = field.Value.ToString(); - // Escape single quotes in string values - if (field.Value is string stringValue) - { - value = stringValue.Replace("'", "\\'"); - whereConditions.Add($"{field.Key} = '{value}'"); - } - else - { - whereConditions.Add($"{field.Key} = {value}"); - } - } } - if (!whereConditions.Any()) + // Use the new batch search functionality for a single key field combination + var keyFieldsList = new List> { requiredFields }; + var batchResults = await BatchFindEntitiesByKeysAsync(entityName, keyFieldsList, cancellationToken); + + // Extract results for the single query (index 0) + if (batchResults.ContainsKey(0)) { - Console.WriteLine("No valid field values provided for duplicate search"); - return new List>(); - } - - var whereClause = string.Join(" AND ", whereConditions); - var query = $"SELECT Id, {string.Join(", ", requiredFields.Keys)} FROM {entityName} WHERE {whereClause}"; - - Console.WriteLine($"Executing duplicate search query: {query}"); - - var queryEndpoint = $"/services/data/v59.0/query?q={Uri.EscapeDataString(query)}"; - var response = await GetAsync($"{_instanceUrl}{queryEndpoint}", cancellationToken); - - if (response?.Records != null) - { - Console.WriteLine($"Found {response.Records.Count} potential duplicates for required fields: {string.Join(", ", requiredFields.Select(kv => $"{kv.Key}={kv.Value}"))}"); - return response.Records; + var results = batchResults[0]; + Console.WriteLine($"Found {results.Count} potential duplicates for required fields: {string.Join(", ", requiredFields.Select(kv => $"{kv.Key}={kv.Value}"))}"); + return results; } Console.WriteLine("No duplicates found"); @@ -900,6 +1521,26 @@ namespace DataConnection.REST.Implementations { [JsonPropertyName("records")] public List> Records { get; set; } = new List>(); + + [JsonPropertyName("totalSize")] + public int TotalSize { get; set; } + + [JsonPropertyName("done")] + public bool Done { get; set; } + + [JsonPropertyName("nextRecordsUrl")] + public string? NextRecordsUrl { get; set; } + } + + public class BatchQueryResult + { + public int QueryIndex { get; set; } + public string Query { get; set; } = string.Empty; + public bool Success { get; set; } + public string ErrorMessage { get; set; } = string.Empty; + public List> Records { get; set; } = new List>(); + public int TotalSize { get; set; } + public string? NextRecordsUrl { get; set; } } private class SalesforceCompositeRequest diff --git a/Data_Coupler/BackgroundServices/ScheduleExecutorService.cs b/Data_Coupler/BackgroundServices/ScheduleExecutorService.cs new file mode 100644 index 0000000..4acdfe7 --- /dev/null +++ b/Data_Coupler/BackgroundServices/ScheduleExecutorService.cs @@ -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; + +/// +/// Servizio di background per l'esecuzione automatica delle schedulazioni +/// +public class ScheduleExecutorService : BackgroundService +{ + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + private readonly TimeSpan _checkInterval = TimeSpan.FromMinutes(1); // Controlla ogni minuto + + public ScheduleExecutorService(IServiceProvider serviceProvider, ILogger 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(); + var dataTransferService = scope.ServiceProvider.GetRequiredService(); + + 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); + } +} \ No newline at end of file diff --git a/Data_Coupler/BackgroundServices/ScheduledJobService.cs b/Data_Coupler/BackgroundServices/ScheduledJobService.cs new file mode 100644 index 0000000..c87eb7a --- /dev/null +++ b/Data_Coupler/BackgroundServices/ScheduledJobService.cs @@ -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; + +/// +/// Background service per l'esecuzione automatica delle schedulazioni +/// +public class ScheduledJobService : BackgroundService +{ + private readonly IServiceScopeFactory _serviceScopeFactory; + private readonly ILogger _logger; + private TimeSpan _checkInterval = TimeSpan.FromSeconds(30); // Controlla ogni 30 secondi per supportare intervalli brevi + private readonly Dictionary _runningSchedules = new(); // Tiene traccia delle schedulazioni in esecuzione + + public ScheduledJobService( + IServiceScopeFactory serviceScopeFactory, + ILogger 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(); + var dataTransferService = scope.ServiceProvider.GetRequiredService(); + + 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(); + var dataTransferService = scope.ServiceProvider.GetRequiredService(); + + _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(); + + // 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); + } +} \ No newline at end of file diff --git a/Data_Coupler/Components/BackupTab.razor b/Data_Coupler/Components/BackupTab.razor new file mode 100644 index 0000000..eef139b --- /dev/null +++ b/Data_Coupler/Components/BackupTab.razor @@ -0,0 +1,558 @@ +@using Data_Coupler.Services +@using Data_Coupler.Models +@inject IBackupService BackupService +@inject IJSRuntime JSRuntime +@inject ILogger Logger + +
+

+ + Backup e Ripristino Dati +

+

+ Gestisci il backup e il ripristino di profili, credenziali, associazioni e schedule. + I backup non includono password o API keys per motivi di sicurezza. +

+ +
+ +
+
+
+
+ + Esporta Backup +
+
+
+

+ Crea un backup completo del sistema con tutti i dati configurati. +

+ + +
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ + + + @if (lastExportResult != null) + { +
+
+
+
+ @(lastExportResult.Success ? "Successo" : "Errore"): +
@lastExportResult.Message
+ @if (lastExportResult.Success) + { + + Durata: @lastExportResult.Duration.TotalSeconds.ToString("F1")s | + Record: @(lastExportResult.ProcessedCounts.Profiles + lastExportResult.ProcessedCounts.Credentials + lastExportResult.ProcessedCounts.KeyAssociations + lastExportResult.ProcessedCounts.ProfileSchedules) + + } +
+ +
+
+
+ } +
+
+
+ + +
+
+
+
+ + Importa Backup +
+
+
+

+ Ripristina i dati da un file di backup precedentemente creato. +

+ + +
+ + +
+ Seleziona un file .json di backup di Data Coupler +
+
+ + @if (selectedBackupInfo != null) + { +
+
+
+
+ + Informazioni Backup +
+
+
+
+
+ Versione: @selectedBackupInfo.Metadata.Version
+ Creato: @selectedBackupInfo.Metadata.CreatedAt.ToString("dd/MM/yyyy HH:mm")
+ @if (!string.IsNullOrEmpty(selectedBackupInfo.Metadata.CreatedBy)) + { + Da: @selectedBackupInfo.Metadata.CreatedBy
+ } +
+
+ Profili: @selectedBackupInfo.Profiles.Count
+ Credenziali: @selectedBackupInfo.Credentials.Count
+ Associazioni: @selectedBackupInfo.KeyAssociations.Count
+ Schedule: @selectedBackupInfo.ProfileSchedules.Count
+
+ @if (!string.IsNullOrEmpty(selectedBackupInfo.Metadata.Description)) + { +
+ Descrizione: @selectedBackupInfo.Metadata.Description +
+ } +
+
+
+
+ + +
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ } + + + + @if (lastImportResult != null) + { +
+
+
+
+ @(lastImportResult.Success ? "Successo" : "Errore"): +
@lastImportResult.Message
+ @if (lastImportResult.Success) + { + + Durata: @lastImportResult.Duration.TotalSeconds.ToString("F1")s | + Record importati: @(lastImportResult.ProcessedCounts.Profiles + lastImportResult.ProcessedCounts.Credentials + lastImportResult.ProcessedCounts.KeyAssociations + lastImportResult.ProcessedCounts.ProfileSchedules) + + } + @if (lastImportResult.Warnings.Any()) + { +
+ Avvisi: +
    + @foreach (var warning in lastImportResult.Warnings) + { +
  • @warning
  • + } +
+
+ } +
+ +
+
+
+ } +
+
+
+
+ + +
+
+
+
+
+ + Backup Recenti +
+
+
+
+

+ Elenco dei file di backup nella cartella Documenti/DataCoupler/Backups +

+ +
+ + @if (recentBackups.Any()) + { +
+ + + + + + + + + + + @foreach (var backup in recentBackups.Take(10)) + { + + + + + + + } + +
Nome FileData CreazioneDimensioneAzioni
+ + @backup.Name + + @backup.CreationTime.ToString("dd/MM/yyyy HH:mm") + + @FormatFileSize(backup.Length) + +
+ + +
+
+
+ } + else + { +
+ +

Nessun backup trovato

+
+ } +
+
+
+
+
+ +@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 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(); + } + + 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]}"; + } +} diff --git a/Data_Coupler/Components/MaintenanceTab.razor b/Data_Coupler/Components/MaintenanceTab.razor new file mode 100644 index 0000000..44b7fe3 --- /dev/null +++ b/Data_Coupler/Components/MaintenanceTab.razor @@ -0,0 +1,573 @@ +@inject ILogger Logger + +
+

+ + Manutenzione Sistema +

+

+ Strumenti per la manutenzione e l'ottimizzazione del sistema. +

+ +
+
+
+
+
+ + Database +
+
+
+

Operazioni di manutenzione del database.

+ +
+
Stato Database
+
+ Dimensione: + @databaseStats.SizeMB MB +
+
+ Ultima ottimizzazione: + @databaseStats.LastOptimization.ToString("dd/MM/yyyy HH:mm") +
+
+ +
+ + + + + +
+
+
+
+ +
+
+
+
+ + Pulizia Sistema +
+
+
+

Strumenti per la pulizia dei dati temporanei.

+ +
+
File temporanei
+
+ Dimensione cache: + @cleanupStats.CacheSizeMB MB +
+
+ File di log: + @cleanupStats.LogFileCount file +
+
+ +
+ + + + + +
+
+
+
+
+ +
+
+
+
+
+ + Monitoraggio Performance +
+
+
+
+
+
+
Memoria Utilizzata
+
+
+
+ @performanceStats.MemoryUsageMB MB / @performanceStats.TotalMemoryMB MB +
+
+
+
+
CPU
+
+
+
+ @performanceStats.CpuUsagePercent% +
+
+
+
+
Connessioni Attive
+

@performanceStats.ActiveConnections

+ di @performanceStats.MaxConnections max +
+
+
+
+
Uptime
+

@performanceStats.UptimeHours h

+ @performanceStats.StartTime.ToString("dd/MM HH:mm") +
+
+
+ +
+ + +
+
+
+
+
+ +
+
+
+
+
+ + Manutenzione Programmata +
+
+
+

Configurazione delle attivitร  automatiche.

+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+
+
+ +
+
+
+
+ + Storico Attivitร  +
+
+
+

Ultime operazioni di manutenzione.

+ +
+ @foreach (var activity in maintenanceHistory) + { +
+
+ + @activity.Description +
+ @activity.Timestamp.ToString("dd/MM HH:mm") +
+ } +
+ + @if (!maintenanceHistory.Any()) + { +
+ + Nessuna attivitร  di manutenzione recente +
+ } + + +
+
+
+
+
+ +@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 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; } + } +} diff --git a/Data_Coupler/Components/SecurityTab.razor b/Data_Coupler/Components/SecurityTab.razor new file mode 100644 index 0000000..f14d1d1 --- /dev/null +++ b/Data_Coupler/Components/SecurityTab.razor @@ -0,0 +1,346 @@ +@inject ILogger Logger + +
+

+ + Sicurezza e Privacy +

+

+ Configurazioni per la sicurezza dei dati e la gestione delle credenziali. +

+ +
+
+
+
+
+ + Crittografia +
+
+
+

Gestione della crittografia delle credenziali.

+ +
+ + Data Protection API Attiva
+ Le credenziali sono crittografate utilizzando la Data Protection API di .NET. +
+ +
+
+ Stato Crittografia: + + + Attiva + +
+
+ +
+
+ Algoritmo: + AES-256 +
+
+ + +
+ + Attenzione: La rigenerazione delle chiavi renderร  inaccessibili le credenziali esistenti +
+
+
+
+ +
+
+
+
+ + Audit e Logging +
+
+
+

Configurazione del logging di sicurezza.

+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+
+
+
+ +
+
+
+
+
+ + Connessioni Sicure +
+
+
+

Impostazioni per le connessioni di rete.

+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + Nota: Disabilitare la validazione SSL solo in ambienti di sviluppo sicuri. +
+
+
+
+ +
+
+
+
+ + Backup Sicurezza +
+
+
+

Configurazione dei backup di sicurezza.

+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + @if (securitySettings.AutoBackupEnabled) + { +
+ + Prossimo backup automatico: Domani alle 02:00 +
+ } +
+
+
+
+ +
+
+
+
+
+ + Azioni Critiche +
+
+
+
+
+
Pulizia Cache Credenziali
+

Pulisce la cache delle credenziali in memoria.

+ +
+
+
Reset Configurazione Sicurezza
+

Ripristina le impostazioni di sicurezza ai valori predefiniti.

+ +
+
+
+
+
+
+
+ +@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; } + } +} diff --git a/Data_Coupler/Components/SystemTab.razor b/Data_Coupler/Components/SystemTab.razor new file mode 100644 index 0000000..22cd1a4 --- /dev/null +++ b/Data_Coupler/Components/SystemTab.razor @@ -0,0 +1,200 @@ +@inject ILogger Logger + +
+

+ + Configurazione Sistema +

+

+ Impostazioni generali del sistema Data Coupler. +

+ +
+
+
+
+
+ + Database +
+
+
+

Configurazione del database principale.

+ +
+ + +
Percorso del database SQLite di sistema
+
+ +
+ +
+
+
+
@databaseStats.TotalProfiles
+ Profili +
+
+
+
+
@databaseStats.TotalCredentials
+ Credenziali +
+
+
+
+ + +
+
+
+ +
+
+
+
+ + Performance +
+
+
+

Impostazioni per ottimizzare le performance.

+ +
+ + +
Numero di record da processare per volta
+
+ +
+ + +
+ + +
+
+
+
+ +
+
+
+
+
+ + Informazioni Sistema +
+
+
+
+
+ Versione Applicazione:
+ 1.0.0 +
+
+ Framework:
+ .NET 9.0 +
+
+ Sistema Operativo:
+ @Environment.OSVersion.Platform +
+
+ Memoria Utilizzata:
+ @GetMemoryUsage() +
+
+
+
+
+
+
+ +@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; } + } +} diff --git a/Data_Coupler/Extensions/DataCoupler/RESTMethod.cs b/Data_Coupler/Extensions/DataCoupler/RESTMethod.cs index 1985fc1..3c1afe4 100644 --- a/Data_Coupler/Extensions/DataCoupler/RESTMethod.cs +++ b/Data_Coupler/Extensions/DataCoupler/RESTMethod.cs @@ -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) { diff --git a/Data_Coupler/Models/BackupModels.cs b/Data_Coupler/Models/BackupModels.cs new file mode 100644 index 0000000..4eeb651 --- /dev/null +++ b/Data_Coupler/Models/BackupModels.cs @@ -0,0 +1,334 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using CredentialManager.Models; + +namespace Data_Coupler.Models; + +/// +/// Modello per l'export/import completo dei dati di backup +/// +public class SystemBackupData +{ + [JsonPropertyName("metadata")] + public BackupMetadata Metadata { get; set; } = new(); + + [JsonPropertyName("profiles")] + public List Profiles { get; set; } = new(); + + [JsonPropertyName("credentials")] + public List Credentials { get; set; } = new(); + + [JsonPropertyName("keyAssociations")] + public List KeyAssociations { get; set; } = new(); + + [JsonPropertyName("profileSchedules")] + public List ProfileSchedules { get; set; } = new(); +} + +/// +/// Metadati del backup +/// +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; } +} + +/// +/// Conteggio record nel backup +/// +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; } +} + +/// +/// Backup di un profilo DataCoupler +/// +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? 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; +} + +/// +/// Backup di una credenziale (dati sensibili esclusi per sicurezza) +/// +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; +} + +/// +/// Backup di un'associazione chiave +/// +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; } +} + +/// +/// Backup di una schedule profilo +/// +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; } +} + +/// +/// Risultato dell'operazione di backup/restore +/// +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 Warnings { get; set; } = new(); + public List Errors { get; set; } = new(); + public string? FilePath { get; set; } +} + +/// +/// Opzioni per l'operazione di backup +/// +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; } +} + +/// +/// Opzioni per l'operazione di restore +/// +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; } +} \ No newline at end of file diff --git a/Data_Coupler/Pages/DataCoupler.razor.cs b/Data_Coupler/Pages/DataCoupler.razor.cs index dc20a3d..bc36aa2 100644 --- a/Data_Coupler/Pages/DataCoupler.razor.cs +++ b/Data_Coupler/Pages/DataCoupler.razor.cs @@ -1812,36 +1812,25 @@ public partial class DataCoupler : ComponentBase } /// - /// 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. /// private string GenerateDataHash(Dictionary 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(); - // 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); } diff --git a/Data_Coupler/Pages/Scheduling.razor b/Data_Coupler/Pages/Scheduling.razor new file mode 100644 index 0000000..d780b72 --- /dev/null +++ b/Data_Coupler/Pages/Scheduling.razor @@ -0,0 +1,350 @@ +@page "/scheduling" +@using CredentialManager.Models +@using CredentialManager.Services +@using Data_Coupler.Services + +Schedulazione Profili + +
+
+
+
+

Schedulazione Profili

+
+ + Storico Esecuzioni + + +
+
+ + @if (schedules == null) + { +
+
+ Caricamento... +
+
+ } + else if (!schedules.Any()) + { +
+ Nessuna schedulazione configurata. + +
+ } + else + { +
+ @foreach (var schedule in schedules.OrderBy(s => s.NextExecutionTime)) + { +
+
+
+
+ "clock", "interval" => "redo", "daily" => "calendar-day", "weekly" => "calendar-week", "monthly" => "calendar", _ => "clock" })"> + @schedule.Name +
+ +
+
+

+ Profilo: @schedule.Profile?.Name +

+ + @if (!string.IsNullOrEmpty(schedule.Description)) + { +

@schedule.Description

+ } + +
+ + Tipo: @schedule.GetScheduleDescription() + +
+ + + @if (schedule.NextExecutionTime.HasValue) + { +
+ + + Prossima esecuzione:
+ @schedule.NextExecutionTime.Value.ToString("dd/MM/yyyy HH:mm") +
+
+ } + + + @if (schedule.LastExecutionTime.HasValue) + { +
+ + + Ultima esecuzione:
+ @schedule.LastExecutionTime.Value.ToString("dd/MM/yyyy HH:mm") +
+
+ } + + + @if (!string.IsNullOrEmpty(schedule.LastExecutionStatus)) + { +
+ "success", "failed" => "danger", "running" => "primary", _ => "secondary" })"> + @schedule.LastExecutionStatus.ToUpper() + @if (schedule.LastExecutionRecordCount.HasValue) + { + (@schedule.LastExecutionRecordCount record) + } + +
+ } +
+ +
+
+ } +
+ } +
+
+
+ + + \ No newline at end of file diff --git a/Data_Coupler/Pages/Scheduling.razor.cs b/Data_Coupler/Pages/Scheduling.razor.cs new file mode 100644 index 0000000..567703b --- /dev/null +++ b/Data_Coupler/Pages/Scheduling.razor.cs @@ -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 Logger { get; set; } = null!; + + protected List? schedules; + protected List? availableProfiles; + protected ProfileSchedule? editingSchedule; + protected DataCouplerProfile? selectedProfile; + protected bool isExecuting = false; + protected List? 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("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("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); + } +} \ No newline at end of file diff --git a/Data_Coupler/Pages/SchedulingHistory.razor b/Data_Coupler/Pages/SchedulingHistory.razor new file mode 100644 index 0000000..3892284 --- /dev/null +++ b/Data_Coupler/Pages/SchedulingHistory.razor @@ -0,0 +1,256 @@ +@page "/scheduling/history" +@using CredentialManager.Models +@using CredentialManager.Services + +Storico Esecuzioni + +
+
+
+
+

Storico Esecuzioni Schedulazioni

+ +
+ + @if (executionHistory == null) + { +
+
+ Caricamento... +
+
+ } + else if (!executionHistory.Any()) + { +
+ Nessuna esecuzione trovata nello storico. +
+ } + else + { +
+
+
+
+
+ Statistiche +
+
+
+
@executionHistory.Count(e => e.Status == "success")
+ Successo +
+
+
@executionHistory.Count(e => e.Status == "failed")
+ Fallite +
+
+
@executionHistory.Count(e => e.Status == "running")
+ In corso +
+
+
+
+
+
+
+
+
+ Record Processati +
+
+
@executionHistory.Where(e => e.Status == "success").Sum(e => e.RecordsProcessed)
+ Totale record elaborati con successo +
+
+
+
+
+ +
+ + + + + + + + + + + + + + + @foreach (var execution in executionHistory.OrderByDescending(e => e.StartTime)) + { + + + + + + + + + + + } + +
Data/Ora InizioProfiloSchedulazioneDurataStatusRecordTriggerAzioni
+
@execution.StartTime.ToString("dd/MM/yyyy HH:mm:ss")
+ @if (execution.EndTime.HasValue) + { + Fine: @execution.EndTime.Value.ToString("HH:mm:ss") + } +
+
@execution.ProfileName
+ @if (!string.IsNullOrEmpty(execution.SourceInfo) || !string.IsNullOrEmpty(execution.DestinationInfo)) + { + + @if (!string.IsNullOrEmpty(execution.SourceInfo)) + { +
S: @execution.SourceInfo
+ } + @if (!string.IsNullOrEmpty(execution.DestinationInfo)) + { +
D: @execution.DestinationInfo
+ } +
+ } +
+
@execution.Schedule?.Name
+ ID: @execution.ScheduleId +
+ @if (execution.Duration.HasValue) + { + @FormatDuration(execution.Duration.Value) + } + else if (execution.Status == "running") + { + In corso... + } + else + { + - + } + + "success", "failed" => "danger", "running" => "primary", "cancelled" => "warning", _ => "secondary" })"> + @execution.GetStatusDisplayText() + + +
@execution.RecordsProcessed
+ @if (execution.RecordsWithErrors.HasValue && execution.RecordsWithErrors.Value > 0) + { + @execution.RecordsWithErrors errori + } +
+
+ + @execution.TriggerType.ToUpper() + +
+ @if (!string.IsNullOrEmpty(execution.TriggeredBy)) + { + @execution.TriggeredBy + } +
+ +
+
+ } +
+
+
+ + + \ No newline at end of file diff --git a/Data_Coupler/Pages/SchedulingHistory.razor.cs b/Data_Coupler/Pages/SchedulingHistory.razor.cs new file mode 100644 index 0000000..e84f58c --- /dev/null +++ b/Data_Coupler/Pages/SchedulingHistory.razor.cs @@ -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 Logger { get; set; } = null!; + + protected List? 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); + } +} \ No newline at end of file diff --git a/Data_Coupler/Pages/SettingsPage.razor b/Data_Coupler/Pages/SettingsPage.razor new file mode 100644 index 0000000..2191d12 --- /dev/null +++ b/Data_Coupler/Pages/SettingsPage.razor @@ -0,0 +1,229 @@ +@page "/settings" +@using Data_Coupler.Services +@using Data_Coupler.Models +@inject IJSRuntime JSRuntime +@inject ILogger Logger + +Impostazioni - Data Coupler + +
+
+
+
+

+ + Impostazioni Sistema +

+ +
+ + @if (!string.IsNullOrEmpty(toastMessage)) + { + + } + + + + + +
+ +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+
+
+
+ + + + +@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); + } +} \ No newline at end of file diff --git a/Data_Coupler/Pages/_Host.cshtml b/Data_Coupler/Pages/_Host.cshtml index 2afe5c7..4d93e47 100644 --- a/Data_Coupler/Pages/_Host.cshtml +++ b/Data_Coupler/Pages/_Host.cshtml @@ -31,6 +31,7 @@ +